diff --git a/docs/design/2026-08-12-session-restage-store-first.md b/docs/design/2026-08-12-session-restage-store-first.md
index 225dc543de..3706de04e0 100644
--- a/docs/design/2026-08-12-session-restage-store-first.md
+++ b/docs/design/2026-08-12-session-restage-store-first.md
@@ -2,36 +2,91 @@
title: Session 终态:非分布式 virtual actor(持久化仅 SQLite)
type: design
date: 2026-08-12
-updated: 2026-09-07(Stage 3 per-session turn 落地:开场激活窗口内的命令走按 sessionId 的队列,分散的计数 / 延迟交接 / 回放删除;Stage 4 降为低优先级;Stage 0 / Stage 1 收尾按升级窗口已关闭排期)
+updated: 2026-09-08(基线切到含 Stage 3 的 master;升级窗口改为「用户手动重启 + 新代码 fail closed 并提示重启」,不再等 fleet 自动重启;按此重写现状差距、收尾 PR 分解与测试面收缩清单。同日二次修订:三项待定项落定——放弃扁平 legacy 行、只读库 fail-fast、导入删除条件;补阅读指引、决策记录与验收标准。三次修订:会话内定位改为 daemon 优先、库兜底(A-9),常态可见性提前进前置 PR(B-11)。2026-09-09 四次修订:撤回「daemon 内部 updateSession 命令化」,Stage 2 结束条件改为事务原语唯一 + 进程边界只走命令)
topic: session-virtual-actor
status: active
-baseline: origin/master@61dadb04c(含已合入的 #852、#1073、#1093、#1051、#1202、#1280)
+baseline: origin/master@0aba0fdd(含已合入的 #852、#1073、#1093、#1051、#1202、#1280、#1308)
references:
- PR #846(会话行唯一写入入口)
- - PR #852(per-bot SQLite + JSON 导入 + 混合窗口;已合入 master)
- - PR #1051(删除 daemon 侧 JSON 写路径 + 行级持久化;已合入 master)
- - PR #1202(Stage 1 occupancy:库内 `occupancy` 租约;已合入 master)
- - PR #1280(Stage 2 单一 apply:`services/session-commands.ts`;已合入 master)
- - Stage 3 per-session turn(`core/session-turn-queue.ts`;本轮落地)
+ - PR #852(per-bot SQLite + JSON 导入 + 混合窗口;v3.18.0)
+ - PR #1051(删除 daemon 侧 JSON 写路径 + 行级持久化;v3.18.12)
+ - PR #1202(Stage 1 occupancy:库内 `occupancy` 租约;v3.19.0)
+ - PR #1280(Stage 2 单一 apply:`services/session-commands.ts`;v3.19.2)
+ - PR #1308(Stage 3 per-session turn:`core/session-turn-queue.ts`;已合入 master,尚未发版)
- #831 / feat/virtual_actor_stage2(不合入;SessionRuntime 只覆盖部分写点的失败记录)
---
# Session 终态:非分布式 virtual actor(持久化仅 SQLite)
-本文是会话态后续实施的唯一口径。旧标题「store-first 重新分步」以及「每步合入必须立刻变简单」不再适用。#852 已把会话行持久化换到 SQLite;后续按终态收拢 occupancy、命令路径和 per-session 串行,不再把这三件事拆成互不相关的独立轨道。
+本文是会话态后续实施的唯一口径。#852 已把会话行持久化换到 SQLite,Stage 1–3 已把 occupancy、单一 apply、开场窗口的 per-session 串行落地。本次更新做三件事:把基线切到含 #1308 的 master;把升级窗口的处理方式从「等 fleet 自动重启」改成「用户手动重启,新代码遇到旧 daemon 时 fail closed 并提示重启」,并据此把所有跨进程兼容路径列为可删;按终态重新度量现状差距,给出收尾 PR 的拆分和可以收缩的测试面。
+
+## 阅读指引(给实施者,含 agent)
+
+本文既是设计口径也是施工清单。按要做的事读对应的节,不必通读:
+
+| 要做的事 | 先读 | 再读 |
+|---|---|---|
+| 理解终态与不变量 | §0、§1 | §2.2 差距表 |
+| 开收尾 PR(删跨进程 JSON 读写、心跳改语义、unmigrated 文案、mojo 修复、会话内定位 daemon 优先) | §5 A、§3.3、§3.4、§3.6、§3.9 | §6 测试面、下方验收标准 |
+| 开前置 PR 里的常态可见性(status 版本列、dashboard 提示、安装提示) | §3.8、§5 B-11 | — |
+| 开前置 PR(descriptor 能力位、supervisor killTimeout、宿主侧租约规则) | §5 B、§3.5、§3.7 | 验收标准 |
+| daemon 侧租约不变量 / lineage 事务收口 / Stage 3 残留 | §5 C、§4 对应 stage | §7 |
+| 判断某条兼容分支能不能删 | §0 原则 7、8;§3.1、§3.2 | 决策记录 |
+
+**决策记录**(已拍板,不要在 PR 里重新讨论;改变需要更新本表):
+
+| 日期 | 决策 | 依据 |
+|---|---|---|
+| 2026-09-07 | 会话库升级窗口按已关闭处理,不再等 fleet 自动重启 | v3.18.0 于 08-28 进 latest |
+| 2026-09-08 | 升级后由用户手动 `botmux restart`;新代码遇旧 daemon 时 fail closed 并提示;不为窗口保留读旧格式分支;不设复核日期 | §3.1 |
+| 2026-09-08 | 心跳探针保留,语义改为「只拒绝、带原因」,不删除 | §3.4:删它会开与版本无关的丢更新窗口 |
+| 2026-09-08 | descriptor 加 presence-based 能力位与仅用于文案的版本号;任何地方不做版本大小比较 | §3.5 |
+| 2026-09-08 | 无 `larkAppId` 的扁平 legacy 行直接放弃;扁平 store 的全部支持点随收尾 PR 删除 | 扁平 store 只存在于 2026-03-11 至 03-22(首次发布到 per-bot 拆分),早于第一个发布 tag v2.16.1(2026-05-07),没有任何发布版本写过它 |
+| 2026-09-08 | 只读库 / `loadFailure` 的 daemon 从「降级运行」改为「有界重试后 fail-fast」 | §3.7:写不了会话库的 daemon 不能服务,降级运行只会让宿主与它竞争 |
+| 2026-09-08 | 一次性导入 + 中毒库恢复的删除条件:latest ≥ v3.19 满 90 天(2026-12-06 之后)且线上 `session-stores/` 的 `*.tmp*` 孤儿核查为零 | §5 C-15 |
+| 2026-09-08 | 会话内定位(`botmux send` 与 detectCurrentSession / resolveSessionAppId)改为「先问活着的 daemon,问不到再读库」;daemon 侧路由补全路由字段 | §3.9:daemon 在时内存行才是权威(原则 4);顺带让旧 daemon 窗口里的 send 降级可达 |
+| 2026-09-08 | 常态可见性(status 版本列、dashboard 提示、安装后提示)从单独排期提前进前置 PR | §3.8:会话里 agent 背后的人看不到事后报错,只能靠事前提醒 |
+| 2026-09-09 | A-8(删扁平 store)拆成紧随 A+B 之后的独立 PR,与 A+B 同时开、先后合;其验收 grep 在 A-8 PR 达标 | 实际改动面是约 20 个测试文件、100+ 处无参 `init()`,会把 A-1 / A-2 / A-9 的协议删除淹没在夹具改写里;扁平 store 在生产上零调用,多留一个 PR 不构成半套协议 |
+| 2026-09-09 | descriptor 的 `supervisorShutdownProtocol` 字段停写,dashboard 前端 `bootstrapRequired` 死分支与 i18n、`RestartLifecycleFlags` 一起删;关停协议常量与关停状态机不动 | 该字段零读者(白名单过滤),前端文案指向已不存在的 flag;「旧进程要重启」由 B-11 的版本可见性覆盖。将来若关停协议再 bump 且需要广告能力,加进与 `sessionStoreProtocol` 同一组能力位并同时加读者 |
+| 2026-09-09 | dashboard 历史弹层的 `sessions.history.staleHint` 改成真判定:仅当该 bot 的 `descriptor.botmuxVersion` 存在、非 `0.0.0`、且与 dashboard 磁盘版本不相等时提示重启;其余只显示原始错误。猜测式原文删除 | 历史 404 是会话里的人会撞到的面;判定只比「是否相等」,不比大小 |
+| 2026-09-09 | **不做** daemon 内部 `updateSession` 的命令化。类型化命令只用于两种转换:跨进程边界的,或多字段必须原子成立且有多个写者的 | 命令化消不掉任何真实故障(丢更新靠租约、交错靠队列、per-field 命令没有 tsc 能守的不变量);138 处必然长期两套写法并存,是 #831 的形状 |
+
+**验收标准**(收尾 PR 与前置 PR 合并前必须全部成立):
+
+```bash
+# A:跨进程 JSON 读写与心跳回落已删
+grep -nE "kind: 'json'|loadFromFrozenJson|abortIf|legacyHeartbeatHeld" src/services/session-store.ts src/services/session-command-host.ts # 期望 0 行
+grep -nF 'sessions-${ctx.currentAppId}.json' src/adapters/cli/fs-policy.ts # 期望 0 行
+grep -nF 'sessions(-[^.]+)?' src/core/mojo-containment-command.ts # 期望 0 行
+grep -nE "withFileLockSync" src/services/session-store.ts # 期望只剩导入与中毒恢复两处
+grep -nE "sessions\.get\(sid\)" src/cli.ts # 期望 0(cmdSend 与会话内定位底座已改走 resolveSessionById)
+grep -rnE "sessionStore\.init\(\s*\)" test | wc -l # 期望 0;在 A-8 PR 达标(A+B PR 里允许非 0)
+# B:descriptor 能力位与关停预算
+grep -nE "sessionStoreProtocol|botmuxVersion" src/daemon.ts src/utils/daemon-discovery.ts src/dashboard/registry.ts # 三个文件都应命中
+grep -nE "PM2_DAEMON_KILL_TIMEOUT_MS" src/core/shutdown-budgets.ts # 断言对象应换成 fleet supervisor 的 killTimeoutMs
+# 构建与测试(与 master 基线 diff,见 memory「botmux 测试基线 diff」)
+bun run build
+bun run test -- test/session-store.test.ts test/session-store-sqlite.test.ts test/session-occupancy.test.ts test/session-delete-cli.test.ts test/whiteboard-unbind-session.test.ts test/fs-policy.test.ts test/mojo-containment.test.ts test/session-turn-queue.test.ts
+```
+
+行为层面的验收:① 用只有 `sessions-.json`、无 `.db` 的数据目录跑 `botmux list` / `botmux delete ` / 会话内 `botmux send`,三者都必须给出 unmigrated 文案而不是「没有活跃会话」或裸栈;② 在库里写一条有效租约、不写 descriptor,`botmux delete` 必须报「daemon 在线」而不是离线关闭;③ 写一个新鲜但无能力位的 descriptor、库里无租约,`botmux delete` 必须报「旧版本 daemon,请先 botmux restart」且行未变、worker 未被 SIGTERM;④ 同③但 descriptor 带能力位,文案必须是「未持有租约」而不是「旧版本」;⑤ 在带 `BOTMUX_SESSION_ID` 的子进程里触发③,输出不得含 `botmux restart`、pid、端口或版本号;⑥ 数据目录只有 `sessions-.json`、无 `.db`,但有一个新鲜 descriptor 指向一个会回答 `GET /api/sessions/:id` 的 daemon(可用测试桩)时,`botmux send` 必须发出(降级到话题根),且返回行的 `larkAppId` 与 env 不一致时必须拒绝;同样场景 daemon 不回答时才落到 unmigrated 提示。
+
+**施工约束**:`/Users/fancy/Code/botmux` 是 live daemon 的运行 checkout,收尾 PR 在 worktree 做,验证止于 build + 单测,不 `switch:here`、不重启;PR 描述用直白中文、写清动了哪些共用路径与各会话类型的验证,不写群内人名与机器人协作花名;行号以本文基线 master@0aba0fdd 为准,动手前先 `git grep` 复核。
## 0. 原则
核心判据是 **架构简明、可维护、可读**:稳态下需要同时理解的协议要少。不要求每个 PR 的 diff 行数立刻变负。
1. **终态优先。** 步骤为终态服务。允许一步只做其中一块,但禁止引入「旧路径完整保留、新层按设计将来整段删除」的平行实现。
-2. **禁止只覆盖部分写点的 actor 层。** 不再引入 `SessionRuntime` / `SessionProjection`、按调用方群组横切、写点台账、审计 gate。occupancy / apply / turn 必须走同一条命令路径,CLI 与 daemon 共用。新增协议时必须写明将被替换的旧协议,以及旧协议的删除条件(可以分 PR 删除,但不能两套所有权长期并存且没有结束条件)。
+2. **禁止只覆盖部分写点的 actor 层。** 不再引入 `SessionRuntime` / `SessionProjection`、按调用方群组横切、写点台账、审计 gate。occupancy / apply / turn 必须走同一条命令路径,CLI 与 daemon 共用。新增协议时必须写明将被替换的旧协议,以及旧协议的删除条件。
3. **边界必须是结构性的**(模块导出、tsc 可检查)。禁止只靠约定维护的边界。
-4. **修改会话状态 = 向该 session 发命令;同一时刻至多一个激活。** daemon 未运行时,不是另开一套磁盘写入协议,而是由宿主 CLI(或同属非沙盒 host 的 supervisor)在本进程成为该行的短生命周期激活、执行同一套 apply。产品语义「daemon 未运行时仍能 close / abandon」保留。沙盒内的 CLI 不在此列(见 §1)。
+4. **修改会话状态 = 向该 session 发命令;同一时刻至多一个激活。** daemon 未运行时,不是另开一套磁盘写入协议,而是由宿主 CLI(或 dashboard)在本进程成为该行的短生命周期激活、执行同一套 apply。产品语义「daemon 未运行时仍能 close / abandon」保留。沙盒内的 CLI 不在此列(见 §1)。
5. **不把旁路存储并入会话库。** turn-sends、frozen-card、whiteboard 文件、usage-ledger、idempotency、vc-meeting-*、`utils/file-lock.ts` 保持独立生命周期。会话行上的 `whiteboardId` 等字段走会话命令;白板正文仍走 whiteboard store。
6. **BotId 仍由地址推导,不引入分配式注册表。** 会话库内的占位租约只表示 occupancy,不是身份注册表。
+7. **低完整性信号只能让判定更严,不能放行写入。** `dashboard-daemons/.json` descriptor 是任何有 `BOTMUX_HOME` 写权限的进程(含 credential-only 沙盒里的会话 CLI)都能改写的文件;它可以作为「拒绝并提示」的依据,绝不能作为「可以写」或「对方是旧版本所以可以绕过」的依据。会话行的权威只有库内租约与已应答的 daemon。
+8. **升级窗口由用户手动关闭。** 升级后不自动重启 fleet。新代码遇到仍在运行的旧 daemon 时,不兼容旧数据格式或旧协议,而是明确失败并提示 `botmux restart`。存储/协议改格式时不再为窗口保留读写兼容分支(见 §3)。
-施工可以分 stage;**稳态下的协议种类必须减少。** occupancy、JSON 回落、离线写、IPC、`abortIf`、mailbox 若无限期并行,读者需要同时记住多套互斥规则。
+施工可以分 stage;**稳态下的协议种类必须减少。** 稳态下会话行的权威判定只有两种输入:库内租约、已应答的 daemon。descriptor 心跳只保留「拒绝」一种作用。
## 1. 终态
@@ -42,7 +97,7 @@ references:
↓
按 sessionId 寻址(bot 级操作按 botId)
↓
- 在 SQLite 事务内读取并获取 occupancy 租约
+ 在 SQLite 事务内读取并判定 occupancy 租约
↓
执行同一套 command apply(与当前 host 进程无关)
↓
@@ -54,203 +109,335 @@ references:
| 部分 | 稳态含义 |
|---|---|
| **身份** | `sessionId` 即寻址键 |
-| **occupancy** | 同一时刻至多一个激活;租约与会话行在同一 SQLite 事务中读写 |
+| **occupancy** | 同一时刻至多一个激活;租约与会话行在同一 SQLite 事务中读写;**持有租约是 daemon 写行的前提** |
| **turn** | 针对该 `sessionId` 的命令在跨 `await` 后仍串行执行。实现是按 session 的 Promise 链 / 队列,不引入新的类型层 |
Host 进程可以更换,apply 实现不能分叉:
-- **daemon 运行中**:由该 bot 的长驻 daemon(或 supervisor 下的 bot 进程)持有激活。进程拓扑与现状相同。
-- **daemon 未运行**:宿主 CLI(或 supervisor)在本进程执行**同一模块**的 close / abandon / prune / 白板绑定(`services/session-commands.ts#applySessionRowCommand`)。临时 host 的激活 = 一次排他的 store 事务(SQLite `BEGIN IMMEDIATE`,或升级窗口内 JSON 的文件锁):事务内读 `occupancy` 行判权威、读新鲜行、apply、发布。**不写租约行**——同一事务内的 claim + release 对其它连接不可观测;而跨多步 abandon 持有租约只会让期间启动的 daemon 按接管规则被判 `displaced`,直到下一个心跳 tick 才重试。多步命令的每一步在各自事务内重验权威。`mutateSessionRowOffline` 这种「任意闭包改行」的入口已删除;非 owner 进程只能对行施加 `HostSessionCommand`。今天走这条路径的只有宿主 CLI 与 dashboard 进程(删板解绑)——supervisor 不写会话行。
-- **沙盒内的 CLI 不能成为 host。** `botmux send` 一类跑在 bwrap / Seatbelt 里的进程读不到 daemon IPC secret(改用本轮的 origin capability 证明身份)。它只能发命令;daemon 不在时它明确失败,不能退化成自己写盘。判定用正向信号(`core/managed-origin-capability.ts#isIsolatedCliProcess`:沙盒 outbox env、宿主打的 read-isolation env、宿主给每种隔离形态都打的 origin channel env、探针 inode 上的内核拒绝),**不用「读不到 secret」**——从未跑过 daemon 的机器上宿主 shell 也读不到 secret,它必须保留离线 close。
- ⚠️ 这道闸的依据是 **confused-deputy**,不是「它反正写不了盘」:只有 full sandbox 对会话库是 readOnly;credential-only 的 bwrap / Seatbelt 只掩掉 `device-auth` 与根级凭据文件,`BOTMUX_HOME`(含 `session-stores/`)对子进程**仍然可写**(见 `worker.ts` 挂载处 “leaving BOTMUX_HOME itself live and writable” 的注释,以及 `isIsolatedCliProcess` 的 docstring)。挡的是被 prompt 注入的 agent 借官方原语离线改会话行——**不要按「反正写不了」把 origin-channel 那条判定删掉**。
+- **daemon 运行中**:由该 bot 的长驻 daemon 持有激活。进程拓扑与现状相同。
+- **daemon 未运行**:宿主 CLI 或 dashboard 在本进程执行**同一模块**的 close / abandon / prune / 白板解绑(`services/session-commands.ts#applySessionRowCommand`)。临时 host 的激活 = 一次排他的 store 事务(`BEGIN IMMEDIATE`):事务内读 `occupancy` 行判权威、读新鲜行、apply、发布。**不写租约行**。多步命令的每一步在各自事务内重验权威。非 owner 进程只能对行施加 `HostSessionCommand`。
+- **沙盒内的 CLI 不能成为 host。** 判定用正向信号(`core/managed-origin-capability.ts#isIsolatedCliProcess`),**不用「读不到 secret」**。
+ ⚠️ 这道闸的依据是 **confused-deputy**,不是「它反正写不了盘」:credential-only 的 bwrap / Seatbelt 下 `BOTMUX_HOME`(含 `session-stores/` 与 `dashboard-daemons/`)对子进程**仍然可写**。**不要按「反正写不了」把 origin-channel 那条判定删掉**。隔离判定必须在任何所有权判定之前执行(今天 `cli.ts` 里它排在 IPC 连接失败后的 `occupancyHeld` 之后,见 §3.6)。
持久化:
-- 运行时唯一的会话行存储是 per-bot `session-stores//sessions.db`。打开连接必须走 `sqlite-compat`(Node `node:sqlite` / Bun `bun:sqlite`),禁止直连。
-- 写入是行级 upsert;`journal_mode=WAL`、`synchronous=NORMAL`(不低于历史上 JSON `tmp+rename` 且不 fsync 的耐久性;本阶段不提高 durability)。
-- 磁盘上可能仍有导入后未删除的 `sessions-*.json`,只作回退到旧版本时的副本,运行时不读不写。发布产物里不再包含 JSON 会话读写实现。
+- 运行时唯一的会话行存储是 per-bot `session-stores//sessions.db`。打开连接必须走 `sqlite-compat`,禁止直连。
+- 写入是行级 upsert;`journal_mode=WAL`、`synchronous=NORMAL`。
+- **跨进程读写只认 `.db`。** 磁盘上的 `sessions-*.json` 只有两个用途:owner daemon 首次启动的一次性导入源、回退到旧版本时的副本。发布产物里不再包含跨进程 JSON 读写实现。
+- 「某个 bot 没有 `.db`」有两种含义,必须区分:① 该 bot 从未启动过 daemon(`sessions-.json` 也不存在)→ 空 store,读为空、写为 `missing`;② 该 bot 的 daemon 仍在跑迁移前的版本,尚未导入(`sessions-.json` 存在)→ 非 owner 进程一律明确失败:「会话库尚未迁移到 SQLite,请重启 daemon(`botmux restart`)后重试」。区分只看文件是否存在,不读内容。
+
+descriptor(`dashboard-daemons/.json`)在终态只有三个角色:IPC 地址发现、dashboard 展示、以及 §3.4 的「拒绝」信号。它不参与放行。
粒度:
-- **寻址和 turn 的键是 session。** 激活可以仍由 per-bot daemon 进程承载(不必引入 Orleans 或跨机器调度)。
+- **寻址和 turn 的键是 session。** 激活可以仍由 per-bot daemon 进程承载。
- 现状是整个 bot 的 `Map` 共用一个进程:该进程退出后,此 bot 下所有会话的内存权威同时失效。终态允许只激活单个 session;SQLite 行级读写已支持这一点。
-- `DaemonSession` 的共享可变别名可以保留,直到有独立的重构理由。去掉别名既不能实现 occupancy,也不等于 mailbox。
+- `DaemonSession` 的共享可变别名可以保留,直到有独立的重构理由。
+
+Mailbox 在本仓库里要解决的问题:飞书、dashboard、CLI、worker 会并发进入同一 `sessionId`,但一条会话一次只应执行一个 turn。终态用按 `sessionId` 的命令队列(`runSessionTurn`)替换分散的串行化实现,而不是再包一层 runtime 类型。
-Mailbox 在本仓库里要解决的问题:飞书、dashboard、CLI、worker 会并发进入同一 `sessionId`,但一条会话一次只应执行一个 turn。JavaScript 单线程不能防止这一点——`await` 之后另一条请求可以插进同一 session。现有 FIFO、generation、inflight、gate、tail-admission 是分散的串行化实现;终态用按 `sessionId` 的命令队列替换它们,而不是再包一层 runtime 类型。
+## 2. 现状与终态的距离(2026-09-08,master@0aba0fdd)
-## 2. 当前基线
+### 2.1 版本时间线
-以 **#852 合入 master(会话行已在 SQLite)** 为基线。#1051 合入后,daemon 进程不再写 JSON。
+| 版本 | 日期 | 会话库形态 | 对升级窗口的意义 |
+|---|---|---|---|
+| < v3.18.0 | — | 只有 JSON | 无 `.db`;这类 daemon 在跑时新 CLI 读不到任何会话 |
+| v3.18.0 – v3.18.14 | 08-28 – 09-06 | 写 SQLite,不建 `occupancy` 表、不写租约(v3.18.0–11 保留了 JSON 写实现但 owner 导入后不再触发) | 有 `.db`、无租约;新 CLI 只能靠 descriptor 心跳知道它活着 |
+| v3.19.0 – v3.19.3(当前 latest) | 09-07 | 写租约 | 租约是权威 |
+| master(含 #1308) | 09-08 | 同上 + 开场窗口 per-session turn | 尚未发版 |
-已具备:
+### 2.2 差距表
-- 会话行的落盘入口在 `session-store.ts`(#846)。其它模块不应再按路径拼装并直接写 `sessions*.json`。
-- per-bot SQLite:整行 JSON 列 + VIRTUAL 生成列;首次 `load()` 使用 `BEGIN IMMEDIATE`;worker `owner: false` 不执行导入。
-- 行级 `persistRow`:不再每次把整个 `Map` 序列化覆盖文件,因此不再出现「外部已提交的行被陈旧整图覆盖」。
-- `closeSession` / `reactivateClosedSession` / mojo journal:先写入副本,成功后再 `Object.assign` 到内存对象(别名保持)。
-- bun 单文件二进制;打开库走 `sqlite-compat`。损坏的 `.db` 与「运行时没有 SQLite 引擎」分开处理。
+| 部分 | 已落地 | 相对终态仍缺 |
+|---|---|---|
+| **身份** | `sessionId` 寻址;跨 store 只读发现(`getSession` → `findInOtherFiles`) | 扁平 legacy store(appId 为空)仍有 8 处支持点(`session-store.ts:440-442/446-448/469-470/725-733/1219-1228/2465/2493-2495`,`init` 的 `appId?`)。这种 store 只存在于项目最初 11 天(2026-03-11 首次发布到 2026-03-22 per-bot 拆分,e5aa28e6),早于第一个发布 tag v2.16.1(2026-05-07):没有任何发布版本写过它,生产上 `init` 只有 daemon 与 worker 两处调用且 appId 必填。**真 bug**:扁平 `sessions.json` 里的行被按 larkAppId 导入 per-bot `.db` 后源文件不删,`readSessionRowCopiesAcrossStores` 把两者算成两个 store,身份扫描答「重复」(实测复现)。 |
+| **occupancy** | 同库 `occupancy` 表;首次 `load()` 的 `BEGIN IMMEDIATE` 内领取;有效租约一票否决宿主写;接管规则看过期与 `ownerPid` 存活 | ① daemon 侧没有任何消费者:`occupancyState` 只用于日志去重(`daemon.ts:22673-22692`),`restoreActiveSessions` 不看 claim 结果,`persistRow` 不查租约——被判 `displaced` 的 daemon 照常 restore / fork / 写行。设计 §1「至多一个激活」在运行时层面没有实现点,今天的租约只是给宿主看的互斥锁。② claim 失败 / displaced 只 warn 并随 30s 心跳重试;`'unavailable'` 连日志都没有。③ 关停期 `claimOccupancy()` 返回值被丢弃(`daemon.ts:23683`)。④ 宿主侧 `occupancyLeaseIsActive` 只看 `leaseUntil`,不看 `ownerPid` 是否存活。⑤ supervisor 默认 `killTimeoutMs` 8s(`fleet-supervisor.ts:138`,`index-supervisor.ts` 未传)小于 daemon 关停预算 28s(`shutdown-budgets.ts:39`),超 8s 的关停被 SIGKILL、租约不释放,留下最长 90s 的「未过期但进程已死」的行;`shutdown-budgets.ts:53-55` 仍在对着 PM2 常量做断言。⑥ 心跳仍作为 `abortIf` 参与判定(§3.4)。 |
+| **apply** | `applySessionRowCommand` 是 close / prune / whiteboard / worker-exited 的唯一变换;宿主与 daemon 的 `closeSession` 都走它;`HostSessionCommand` 用 `never` 把 daemon 专属字段挡在 tsc 边界外 | ① daemon 内部的 `sessionStore.updateSession(session)` 全仓 138 处 / 14 个模块,形态是「就地改对象 + 整行 upsert」。这是激活自己改自己的状态,**不要求命令化**(决策记录 2026-09-09):对它们的约束只有三条——写前持有租约(C-11)、有交错证据的段落用 `runSessionTurn` 包住(它入队的是闭包,不需要命令类型)、多字段必须原子成立的转换用 `closeSession` 那种「副本 → 变换 → 落盘 → 合并回内存」的形态(`reactivateClosedSession`、mojo journal 已是;`admit` / `promote` 见 Stage 3 残留)。② `persistActiveRemoteLineage*` 三个函数(`session-store.ts:1435/1498/2203`,约 240 行)是第二套手写行级事务,`SessionRowCommand` 没有 lineage 命令;`'postrename_ambiguity'` 是 JSON 时代的命名。③ `whiteboard-store.ts:466-486`:daemon 应答了非 2xx 非 409(如 400/500)也落到宿主 apply,与 `cli.ts:3856` 的「已应答即权威」不同构。 |
+| **turn** | `runSessionTurn` 按 `sessionId` 的 Promise 链;开场激活窗口内的 follower 落盘、开场释放都走它;`hasPendingSessionTurns` 替代计数 | ① `admitQueuedActivationTail` / `promoteQueuedActivationTail` 仍在 store 外备份再回滚(promote 4 份快照),且 promote 的回滚漏还原 `ds.session.queued`(`worker-pool.ts:9742` vs `9784-9787`;对照同文件 `10294` 有还原)。② `initialStartClaimToken` 在 worker-pool 有 4 处绕过 token fence 的裸清除(`9159/10670/11585/14246`)。③ `utils/anchor-serializer.ts#serializeByAnchor` 仍是第二套按会话串行(键是 anchor 不是 sessionId),5s 后主动放弃排序,4 个消费点在 `event-dispatcher.ts`。④ `session-store.ts:1660` 的 `bridgeMarkerCleanupFences` 是第三个 per-session Promise fence,给旁路文件排序却住在会话库模块里。⑤ `hasQueuedActivationAdmissionGate` 有 5 个消费点、4 类后果:两处改路由进 durable tail,一处抑制 live takeover,`daemon.ts:19886` 直接拒绝 passthrough 命令,`trigger-session.ts:1191` 对带幂等 key 的 HTTP trigger 回 `trigger_failed`——Stage 3 之后队列上一条几毫秒的 release 命令就能让后两处在正常时序下拒绝用户。⑥ `hasPendingSessionTurns` 把正在运行的命令自己也算 pending,「命令内不得对同 session 再入队」只靠注释维护。 |
+| **持久化仅 SQLite** | daemon 只写 SQLite(#1051) | 跨进程 db-else-json 仍在:`StoreFileRef.kind` 分流 11 处(含 `session-store.ts:1700` 的内联字面量)、`resolveStoreFile` / `listStoreRefs` 的 JSON 登记、三个读原语(`readStoreEntries` / `readStoreRowByKey` / `readStoreActiveRows`)与 `countActiveSessionsOnDisk` 的 JSON 分支、`getSessionFresh` 的 JSON 文件锁读、`owner:false` 无库时的 `loadFromFrozenJson`、`runUnownedRowTxn` 的 JSON 文件锁写分支、`fs-policy.ts:825-828/1002-1007` 对 `sessions-.json` 的只读授权。 |
+| **host 可更换 / 沙盒只发命令** | CLI、dashboard 都能做宿主;沙盒 CLI 用正向信号拒绝离线写 | `cli.ts` 里隔离判定(`3924`、`3823`)排在 IPC 连接失败后的 `occupancyHeld`(`3915`、`3850`)之后:隔离 CLI 可能先拿到宿主专用的报错文案。 |
-相对终态仍缺:
+### 2.3 与升级窗口无关、现在就错的项
-- **occupancy 已在同库 `occupancy` 表。** 租约在 `BEGIN IMMEDIATE` 内判定,有效租约一票否决离线写;`findOnlineDaemon` 不再是唯一所有权来源。没有有效租约(缺行 / 过期 / 不可读)时心跳仍参与判定——这是升级窗口(只写会话行、不写 occupancy 的 daemon,含回滚后的旧构建)。删除该回落的条件与 Stage 0 JSON 读路径相同。
-- **apply 已收成一份**(Stage 2)。行级变换只在 `services/session-commands.ts`:daemon 的 `closeSession` / `/whiteboard` 路由与宿主的 `services/session-command-host.ts`(`applySessionCommandAsHost` / `readSessionRowAsHost`)都调用它。仍分开的是**运行时拆除**(daemon 的 killWorker / remote cancel prepare vs 宿主 CLI 的 SIGTERM + backing 销毁)与 close 后的旁路清理(宿主只做 dashboard 图片目录清理;turn-sends / prompt-ctx / frozen-card 仍由 daemon 清)。
-- **per-session turn 已有(Stage 3)。** `core/session-turn-queue.ts#runSessionTurn` 是按 `sessionId` 的 Promise 链;开场激活窗口内的命令(后到消息的 prompt 构造 + durable tail 落盘、开场 ACK 对路由的释放)都走它。仍在队列外的是 fork 边界的所有权 `initialStartClaimToken`(跨越整段资源准备,见 Stage 3「有意保留」)与 pendingRepo 等待期的缓冲(等人,不是等 `await`)。
-- **跨进程仍可能读 JSON**(#1051 保留):当 CLI 已升级、daemon 仍在写 JSON 时,快照、点读、身份扫描、worker、`owner: false` 走 db-else-json。这是迁移兼容,不是终态。升级窗口已按关闭处理(见 Stage 0 的状态记录),这些分支在收尾 PR 里删除。磁盘上的冻结 JSON 文件可以保留。
+这些不依赖任何删除条件,应先于收尾 PR 或与之同 PR 修掉:
-#831 / `SessionRuntime` **不合入**。失败原因是只把约 32% 的写点迁入新层、旧 API 完整保留、约 17k 行适配层按设计要整段删除,并在 build 上挂审计脚本。这不能证明会话桥不该用 virtual actor。写点地图和 receipts/lane 只作线索,立项前在现行代码上复核。
+- `core/mojo-containment-command.ts:68-94#defaultIsSessionActive` 仍扫 `sessions*.json`。导入后新建的会话在 JSON 里不存在 → 返回 `false`(「已证明不活跃」)→ revoke 安全闸静默放行;导入前就存在的会话读到冻结快照里的陈旧 status。它还无视注入的 `deps.dataDir`。测试 `mojo-containment.test.ts:422` 用 `sessions-app.json` 播种,把这个错误实现锁死了。
+- 扁平 `sessions.json` 与 per-bot `.db` 被算成两份身份拷贝(见 2.2 身份行)。
+- `promoteQueuedActivationTail` 回滚漏 `queued`(见 2.2 turn 行)。
+- `test/trigger-session-root-message.test.ts:72-74` 手抄的 `hasQueuedActivationAdmissionGate` 缺 `hasPendingSessionTurns` 一项,master 上已与生产分叉。
+- descriptor 字段 `supervisorShutdownProtocol` 写入但零读者(`daemon-discovery.ts:78-91` 的字段白名单把它过滤掉了);dashboard 前端仍保留服务端已删的 `bootstrapRequired` 分支,文案指向已不存在的 `botmux restart --bootstrap-shutdown-protocol` flag;`cli.ts:2650-2655` 的 `RestartLifecycleFlags` 零引用。
+- 死代码:`session-store.ts:632-635` `__testOnly_setAfterRemoteBatchRename` 零调用方;`test/session-store.test.ts:25` 拦截 `sessions.json.` 写入的 fs mock 注入今天就永不触发。
+- 过时注释:`daemon.ts:4212-4214`「mtime drives offline detection」(实际判据是 `daemon-discovery.ts:77` 的 `lastHeartbeat` 字段);`dashboard-ipc-server.ts:2247`「cross-scans every bot's sessions-*.json」。
-## 3. 后续 stage(#852 之后)
+## 3. 升级策略:手动重启 + fail closed 提示
-从会话行已在 SQLite 起重新划分。旧 Step 1–5(摘取缺陷 / 唯一写入入口 / 换引擎 / 按痛点加事务 / 归档)只记录已完成工作,不再当路线图。
+### 3.1 决定
-### Stage 0 — 删除 daemon JSON 写路径【#1051 已合入;收尾:JSON 读路径】
+- 不再实现、也不再等待「升级后自动重启 fleet」。删除条件里所有「fleet 自动重启落地」的表述作废;原定 2026-11-26 的兜底复核点也作废。
+- 升级后由用户手动 `botmux restart`。在此之前,新代码(CLI、dashboard、由旧 daemon 从新 dist 拉起的 worker)遇到旧 daemon 时**不兼容旧格式、不兼容旧协议**:明确失败,给出一句带命令的中文提示。
+- 提示是产品行为,不是兼容路径:它不读旧格式、不代旧 daemon 做任何事,只把「你还欠一次重启」告诉人。
+- 今天升级链路上只有 `botmux upgrade`(`cli.ts:3189/3222`)与 dashboard 的更新确认框说过「请 botmux restart」;npm/bun 的 postinstall 与 `install.sh` 零提示;自动更新(`maintenance.ts`)与 `autoRestart` 默认都关。
-**目标**:daemon 只写 SQLite;JSON → SQLite 导入正确;运行时更新走行级 upsert。
+### 3.2 三类旧 daemon 在新代码下的表现
-#1051 已覆盖本 stage 中收益最大的部分(§4)。本 stage 只收尾,不要把 occupancy 放进同一 PR。
+| 仍在运行的 daemon | 新 CLI / dashboard / worker 的表现 |
+|---|---|
+| < v3.18.0(无 `.db`,有 `sessions-.json`) | 会话内定位走 daemon 优先(§3.9):`botmux send` 与会话内的 history / whiteboard / schedule 等从活着的旧 daemon 拿到路由,降级但可达。其余跨进程读(`botmux list`、`delete` 的离线路径、dashboard 删板、worker `owner:false` 的 `load()`)遇到该 bot 时得到 `unmigrated`:读侧不把该 bot 计入结果并在 stderr 打一行提示;写侧返回独立判别值并提示。IPC 路径不受影响。 |
+| v3.18.0 – v3.18.14(有 `.db`,无租约) | IPC 能应答的走 IPC,daemon 权威。IPC 不可达而落到宿主写时:无有效租约 + descriptor 新鲜 → 拒绝,提示「后台 daemon 是升级前的旧进程,请先运行 `botmux restart`」。 |
+| ≥ v3.19.0 | 租约是权威;descriptor 新鲜但无租约时同样拒绝,但文案是「daemon 在线但暂未持有会话库租约」(见 §3.5)。 |
-已纳入 #1051 的 Stage 0 缺口:
+会话内 agent 的 `botmux send` 今天读会话库取 `chatId` / `larkAppId`(`cli.ts:8571-8613`;Linux bwrap 下由宿主 re-exec 读,riff 沙盒例外用 env)。按 §3.9 改为先问活着的 daemon 之后,它在 < v3.18.0 的 daemon 下**降级但可达**:消息落在正确的话题里,但旧 daemon 返回的行没有每轮的精确回复锚点与 codex 派发台账,回复可能落到话题根、@回发送者不精确。仍然发不出的只剩两个角落:macOS 凭证隔离的会话 CLI 读不到 IPC secret、只能读库;v2.x(2026-07-16 之前)的 daemon 不认宿主 HMAC。两者都退回 unmigrated 提示。
-- dashboard 删除白板:daemon 运行中经 IPC 解绑,daemon 不可见时才离线写。两条路径都对板 id 做比对后再改——删板已经先把板移出 index,daemon 的 `ensureSessionWhiteboard` 会在该会话下一轮立刻补一块新板,无条件清除会把这块新绑定一起抹掉。IPC 侧的比对是路由新增的 `expectWhiteboardId`(不匹配返回 409)。
-- 因 daemon 可见而没能解绑的会话计入 `unresolvedSessions` 返回,不再和「没有会话引用这块板」一样报 0。
-- 解绑的 daemon IPC 带超时:心跳新鲜但 socket 不响应的 daemon 不能把 dashboard 的删除请求一直挂住。
-- 离线写收敛为一个入口 `services/session-offline-write.ts#mutateSessionRowWhenUnowned`(`mutateSessionRowOffline` + 心跳探测),CLI 与 whiteboard-store 共用;CLI 私有的那份心跳解析与 90s 判定删除,改用 `utils/daemon-discovery.ts`。探测与 store 读写用同一个 dataDir。
-- `mutateSessionRowOffline` 的 sqlite 路径在 `openDbForOwnStore` 前再做一次 `existsSync`:读写 open 会创建空库,导致导入门把尚未导入的 store 当成已导入。
-- PR 标题与描述以「daemon 不再写 JSON;跨进程在升级窗口内仍可读 JSON」为准,不再写全仓 db-only。
+### 3.3 「无 `.db`」的判别与文案落点
-**JSON 读路径的删除条件(两条,先到先算):**
+- `session-store` 对每个 store 给出三态:`ready`(有 `.db`)、`unmigrated`(无 `.db`、有 `sessions-.json`)、`absent`(两者都无)。判别只做 `existsSync`,不解析 JSON。**只对仍然存在的 bot 判 `unmigrated`**:bots.json 里配置的、当前有 descriptor 在线的、以及本进程所属的 appId(`services/known-bot-app-ids.ts`)。被移出 bots.json 的 bot 留下的 `sessions-.json` 是废弃数据,没有任何 daemon 会再导入它,不能让 `botmux list` 永远提示「请重启 daemon」(dogfooding 时本机就有 4 份这样的残留)。这是删除 JSON 读路径后 store 对 JSON 文件名仅存的两处认知之一(另一处是导入源)。
+- `UnownedRowBlocked` 增加 `unmigrated` 判别值,与 `missing`(行不存在)分开;`loadAllSessionsSnapshot` 的返回带上 `unmigratedAppIds`。
+- 一条共享文案常量,三个落点:`cli.ts#loadSessions()` 之上做一次「本次命令涉及哪些 appId」的统一判定并打印;`offlineBlockedError` 映射 `unmigrated`;`whiteboard-store#deleteWhiteboard` 的返回增加 reason 通道(今天只有 `unresolvedSessions` 计数,dashboard 看不到原因)。
+- 「没有活跃会话。」这句话有 4 个出口(`cli.ts:4960` cmdList、`4985` cmdDelete、`6061` cmdTermLink、`4565` TUI 空态);有 `unmigrated` store 时都不得打它。
+- 跨 bot **读**路径(`inherit-peer.ts:62-63` 继承 workingDir、`schedule-follow-active.ts:126` 落点选择、`command-handler.ts:5259` /adopt 去重、`restart-report.ts:132` 计数)在删除 JSON 读后对未迁移 bot 静默降级:不继承、不排除、少计。它们不是话题接管判定,接受降级,但 `restart-report` 要接住 `SessionStoreSqliteUnavailableError`(`countActiveSessionsOnDisk` 会把它抛出)。
-1. 升级后自动重启 fleet 落地——线上不再有仍在写 JSON 的 daemon。
-2. 兜底复核点 **2026-11-26**(本文 2026-08-28 定稿起 90 天)。届时若 fleet 自动重启仍未落地,就按当时 latest 与「`sessions.db` 首次进入 latest 的版本」之间的跨度,决定直接删除还是再延一期,并把结论写回本节。
+### 3.4 心跳的定性:只拒绝、不放行
-第 2 条是必需的:fleet 自动重启不在本文范围,也没有承诺时间点。只写第 1 条,等于把跨进程 JSON 读做成 §不做 明令禁止的「长期不变量」。
+设计文档原计划「删 `abortIf` 心跳回落,`isOccupancyHeld` 只看租约」。**这一条按字面执行是回归**,原因与升级窗口无关:
-**状态(2026-09-07)**:SQLite 引擎首次进入 latest 是 v3.18.0(2026-08-28),daemon 不再写 JSON 的版本是 v3.18.12(2026-09-02),occupancy 租约的版本是 v3.19.0(2026-09-06)。维护者已决定不再等 fleet 自动重启,按升级窗口已关闭处理;收尾 PR(本节的 JSON 读路径删除 + Stage 1 的心跳回落删除)排在 Stage 3 之后统一做,范围见 §5。
+- `sqliteOccupancyBlocksWrite = 租约有效 || abortIf()`(`session-store.ts:354-360`)。`abortIf` 是**收紧**信号,删掉它等于放宽。
+- 存在与版本无关的「daemon 活着但库里没有有效租约」:被判 `displaced` 的 daemon 只 warn、每 30s 重试、期间 `persistRow` 照常写;claim 抛错(`'error'`)的 daemon 同样握着完整内存缓存照常写。今天挡住宿主写的只有 descriptor 心跳。(`'unavailable'` 的三个条件与 `persistRow` 的两条 throw 互斥,不构成缺口。)
+- 正常 `botmux restart` 是 stop-then-start,supervisor 等每个子进程 exit 后才退出,不产生重叠;但 daemon 关停超过 supervisor 的 8s `killTimeoutMs` 就会被 SIGKILL,租约不释放。这不是罕见异常(remote backend 的 drain 就有 12s),先修 killTimeout 再评估剩余窗口。
+- 宿主写不是通用路径:`cli.ts` 的 delete / prune / whiteboard 都先走 IPC,已应答的 daemon 无论租约如何都是权威(`3856-3858`、`3908-3912`)。心跳只在「descriptor 新鲜但 IPC 连不上」这一子集里起作用。但这个子集里 `botmux delete` 会先 SIGTERM 行上记录的 worker、拆 tmux/backing(`cli.ts:3719-3725`、`3750-3785`),再写行——不可回滚。
-条件满足后,再开一个仍属 Stage 0 的 PR,从代码中删除 JSON 读路径(不必等 occupancy):
+因此心跳探针**保留**,但定性从「升级窗口的兼容回落」改为「只会拒绝、永不放行的存活兜底」,并把结果从静默 `owned` 改成带原因的拒绝:
-- 删除跨进程 JSON 读/写、`StoreFileRef.kind` 分流、沙盒对冻结 JSON 的授权。
-- 若无 `.db` 且尚未导入:失败并提示重启 daemon 完成迁移,不再实现完整的 JSON 离线写。
-- 线上不再有仍在写 JSON 的 daemon 之后,删除导入实现及其文件锁;新 bot 直接创建空库。
-- 磁盘上的冻结 JSON 文件可以保留,供回退旧版本读取。
+```
+在 BEGIN IMMEDIATE 内:
+ lease = 读 occupancy 行
+ 若 lease 存在 且 (leaseUntil > now 或 processAlive(ownerPid))
+ → owned{heldBy:'lease'} # 调用方走 IPC / 报「daemon 在线」
+ 否则 若 descriptor 新鲜(lastHeartbeat 90s 内)
+ → descriptor 声明了 sessionStoreProtocol → owned{heldBy:'daemon_without_lease'}
+ → descriptor 未声明(旧版本) → owned{heldBy:'legacy_daemon'}
+ 否则 → 宿主在同一事务内 apply(保留的产品语义)
+ 库不可读 / 无 SQLite 引擎 → owned{heldBy:'store_unreadable'}(不再抛裸栈)
+```
-删除条件满足之前,#1051 保留 db-else-json:升级窗口内 `botmux send` 必须仍能读到会话。这不是终态要求。
+- `UnownedRowOptions.abortIf: () => boolean` 换成 `probeHolder: () => HolderReason | undefined`,`UnownedRowBlocked` 的 `owned` 带 `heldBy`。这是把已有的 boolean 换成有信息量的枚举,不新增协议。
+- 探针仍在事务内、只判一次。`session-store.ts:2625` 发布前的第二次判定随之删除:租约在 `2617` 只读一次、两次判定复用同一个快照,事务内其它连接改不了 occupancy 行,第二次判定的唯一活项就是 `abortIf`。
+- 宿主侧租约有效性从「只看 `leaseUntil`」改为「未过期 或 `ownerPid` 存活」。方向是 fail closed:进程卡住 / 休眠导致两个信号同时过期时仍拒绝。PID 复用会导致误拒(表现为宿主暂时不能离线 close),要彻底解决需在 occupancy 行里存 `processStartIdentity`(schema 是 `CREATE TABLE IF NOT EXISTS`,加列要显式 ALTER),留到 §5 C 项。daemon 侧 `claimOccupancyInTxn` 的接管规则(未过期且进程存活才让路)方向不同,不改。
+- `isOccupancyHeld` 只剩 `cli.ts:3696` 一个调用方,改为返回同一个枚举;`hostOptions` 里「无 `larkAppId` 的行不探测」随扁平 store 一起删除(§5 A-8)。
+- 宿主的 `openDbForOwnStore` 在判定之前就对目标库执行 `CREATE TABLE IF NOT EXISTS occupancy`:在 v3.18.x 的库上这是一次真实的 schema 变更,也抹掉了「有没有 occupancy 表」这个版本判据。收尾时先用 `openDbForRead` 读租约与 descriptor,确认无人持有再升级为 own-store open。
-### Stage 1 — Occupancy 写入 SQLite【已落地】
+### 3.5 descriptor 能力位与版本号
-**目标**:occupancy 与会话行在同一事务中读写。这是 grain directory(哪个进程持有激活),不是 actor 框架。
+- `DaemonDescriptor` 增加 `sessionStoreProtocol: 'occupancy-v1'`(presence-based,与已有的 `workflowIpcProtocol: 'v1'` 同构),随每次 30s 心跳整体重写。旧 daemon 天然不写它。**它只决定文案,不决定是否放行**:新 daemon 在启动窗口(`daemon.ts:22671` 先写 descriptor,`22694` 才 claim)与 claim 持续失败时同样是「有字段、无租约、活着」。
+- 另加 `botmuxVersion: string`(取 `utils/install-info.ts#botmuxVersion()`),**只用于文案**。不做版本大小比较:`package.json` 的 version 是 `0.0.0`,源码 checkout 与本地编译的二进制都报 `0.0.0`,而 live fleet 就是从 checkout 起的;canary/beta 后缀与回滚场景也让「谁新」没有唯一答案。
+- 读侧两处都要改:`daemon-discovery.ts:19-30/78-91`(字段白名单)与 `dashboard/registry.ts:5-28`(整体 cast)。`supervisorShutdownProtocol` 字段停写并删除其前端死路径(决策记录 2026-09-09);`core/supervisor-shutdown-protocol.ts` 的常量与关停状态机不动,`test/shutdown-supervisor-contract.test.ts` 改为不再断言该字段。
+- descriptor 的新鲜度只看文件内 `lastHeartbeat` 字段,不看 mtime、不查 pid。`cli.ts:2578` 按 mtime 清 5 分钟前的 descriptor 不会把「拦住」翻成「放行」。闸绝不能把「descriptor 文件不存在」解读成「旧版本 daemon」。
-已落地:
+### 3.6 受众与文案
-1. 同库表 `occupancy(scope, owner_pid, boot_id, lease_until)`。v1 只有 `scope='bot'`;主键是 `scope`,不排除将来 `session:`。
-2. 拥有 store 的 daemon 在首次 `load()` 的 `BEGIN IMMEDIATE` 事务里领取占位(`init(..., { occupancy })`)。领取是有条件的:别的 boot 的租约只有在过期、或其 `owner_pid` 已不存在时才会被接管;仍然存活的前任保留所有权,后任记 warn 并在心跳里重试。领取失败(如只读库)只记 error,不阻止快照加载。descriptor 文件仍写,只作 IPC 发现。
-3. 非当前 host 的 SQLite 写入:`BEGIN IMMEDIATE` → 读租约 → 有效则中止;没有有效租约时再看心跳(`abortIf`);两者都不在场才允许宿主在同一事务内 apply(不写租约行,见 §1)。
-4. 领取与续期是同一条语句(`claimOccupancyLease`),随 descriptor 心跳每 30s 执行,首次 load 之后立即执行一次(reconcile 可能已经提前触发过 load)。TTL 与心跳 staleness 共用 `DAEMON_HEARTBEAT_STALE_MS`(90s)。优雅关停期间租约一直持有到 `process.exit` 前才按 `boot_id` 释放——teardown 中 worker 仍在写回缓存;`exit` handler 兜底。
-5. 所有权调用点:`applySessionCommandUnowned` / `applySessionCommandAsHost`、CLI close / abandon / prune、whiteboard 离线解绑。`findOnlineDaemon` 用于 IPC 地址、dashboard 展示,以及无有效租约时的心跳回落。已经**应答**的 daemon(任何 HTTP 状态)始终权威:它的拒绝是终态,不因租约状态回落到离线写;只有连接失败时才用 `isOccupancyHeld` 区分「daemon 在但不可达」与「descriptor 是残留」。
+- 提示只给**写命令**:`botmux delete`、`botmux list` 的自动 prune(按命令去重,只打一次)、`botmux whiteboard` 绑定、dashboard 删板。`botmux send` 不写会话行,它的失败只有 `unmigrated` 一种(§3.3)。`restart / stop / start / status / upgrade / setup / dashboard` 必须无条件放行。
+- 按「是否会话子进程」分流:有 `BOTMUX_SESSION_ID` 或 origin channel 的进程只陈述状态(「daemon 当前不接受会话库写入,本次未做任何修改」),**不给 `botmux restart` 这样的 fleet 级指令**,也不带 pid / 端口 / 版本号——完全不开沙盒的会话里 CLI 子进程是合法宿主,会读到这段文案,一个照做的 agent 会重启整个 bot。带版本号与命令的文案只在操作员 shell 输出。
+- 隔离判定(`isolatedCliProcess()`)前移到 `cli.ts:3850` / `3915` 的 `occupancyHeld` 之前,保证隔离会话只看到通用文案。
+- 本机多 checkout 场景:CLI 的 dist 与 daemon 的 dist 经常来自不同 checkout(`bun run build` 故意不认领全局指向)。文案要说清是「运行中的 daemon」旧,不能一律建议 `botmux restart`(那会让 review worktree 抢走全局指向);`0.0.0` 一律按「无法判定」处理。
+- 现成先例:`cli.ts:3690` `ISOLATED_CLI_OFFLINE_ERROR`、`core/session-marker.ts:118`。
-**心跳回落(不是长期双协议)**:没有有效租约时,仍用心跳判断「未写 occupancy 的 daemon 是否在线」——包括未升级的 #1051 daemon,也包括新构建崩溃留下过期行后回滚运行的旧构建。有效租约存在时心跳不再能放行(心跳陈旧也中止)。删除该回落的条件与 Stage 0 JSON 读路径相同(fleet 自动重启落地,或 2026-11-26 复核)。
+### 3.7 daemon 侧不变量:持有租约才写
-仅用 `BEGIN IMMEDIATE` 替换心跳探测不算完成(已用租约表达「另一进程仍持有内存缓存」)。禁止 daemon 未运行时提交 close / abandon 也不算完成:产品语义保留,Stage 2 改为宿主在本进程执行同一 apply。
+租约要从「给宿主看的公告牌」变成 daemon 自己的前提,否则 §1 的「至多一个激活」只是一句话:
-### Stage 2 — 单一 apply 路径【已落地】
+1. `claimOccupancy` 改成状态机:`held` / `pending` / `lost`。启动阶段 claim 失败(displaced / unavailable / error)进入 `pending`,按 1s 间隔重试(今天只有 30s 心跳一个重试点);重试上限取关停预算上界加一个 TTL(2–3 分钟量级);超窗 `logger.error` 后非零退出,文案区分「另一个 daemon 仍持有本 bot 的会话库」与「会话库不可写/不可读:{原因}」。`'unavailable'` 必须记日志。
+2. 运行阶段 renew 返回 `displaced`(bootId 与 pid 都不同、对方未过期且存活,是真被接管)⇒ 停止接受新 turn、停止 restore / fork,走 shutdown,不再 `persistRow`。
+3. `persistRow` 断言本 boot 仍持有租约(内存里的 `leaseHeldUntil` 时间戳,由 claim 更新),超期未续 ⇒ 抛 `SessionStoreUnavailableError`,不静默写。`owner:false` 的 worker 与不配 holder 的测试夹具排除在外。
+4. 关停期 `daemon.ts:23683` 的 `claimOccupancy()` 返回值必须检查;teardown 期间保留一个只做续租的短定时器(今天 `23675` 清掉心跳后只靠一次 90s 续租)。
+5. supervisor `killTimeoutMs` 与 daemon 关停预算对齐(≥ 28s + 余量),`shutdown-budgets.ts:53-55` 的断言改成对 fleet supervisor 生效。
+6. `restoreActiveSessions` 与 tmux adopt 以 `held` 为前置:被 displaced 的 boot 不得 re-attach 同一批 tmux 会话(今天两个 daemon 会同时向同一个 pane 写)。
-**目标**:close / abandon / 解绑白板 / prune 等命令只有一份实现。依赖 Stage 1。
+**已决定的行为变更**:只读库 / `loadFailure` 的 daemon 从「降级运行(`claimOccupancyOnLoad` 注释明写有意容忍,收尾时改掉这段注释)」变成「有界重试后 fail-fast」。写不了会话库的 daemon 本来就不能服务,降级运行只会让它与宿主竞争同一批行;退出时的错误信息要带上 `loadFailure.message`,让 `botmux status` 与 supervisor 日志能直接看到原因。
-已落地:
+不变量落地后,宿主侧仍然不能只看租约:它管不了 ≤ v3.18.14 的存量 daemon,也管不了进程卡死 > 90s。两者互补:不变量把 displaced 窗口压到 1s 量级并让 daemon 自杀,心跳探针把旧 daemon 与卡死的 daemon 挡在门外。
-1. `services/session-commands.ts#applySessionRowCommand(row, command, { now })`:`close` / `prune` / `whiteboard` / `worker-exited` 四条命令对行的唯一变换。纯函数、不做任何 I/O(它在宿主路径上跑在 `BEGIN IMMEDIATE` / 文件锁之内);close 时的 token 快照由调用方在锁外采样后作为命令字段传入。幂等:对已关闭行再 close **不刷新 `closedAt`**;宿主 close(无 daemon 专属字段、无残留 runtime 字段)是 `noop`。daemon 专属的 park / journal wipe 在已关闭行上仍可落地——并发二次 close 输掉 status 竞态时不能把 residual 丢掉。
-2. daemon 侧 `session-store.closeSession` 与 `/api/sessions/:id/whiteboard` 路由改为调用它;daemon 独有的 close 输入(`tokenUsage`、`parkMojoLineage`、`parkLocalResidual`、`clearRiffParentTaskId`、`clearMojoCloseJournal`)在 `HostSessionCommand` 上被类型化为 `never`——宿主构造不出能抹掉 mojo 对账栅栏或钉死 token 快照的命令,边界由 tsc 检查。
-3. `session-store.mutateSessionRowOffline(target, 闭包)` 删除,替换为 `applySessionCommandUnowned(target, HostSessionCommand)` 与 `readSessionRowUnowned(target)`(同一事务、同一权威判定、不写)。结果是判别联合:`applied` / `noop` / `refused(reason)` / `owned` / `missing` / `contended`——不再用 `undefined` 混同「被占用」「行不存在」「锁竞争」。
-4. `services/session-offline-write.ts` 改名为 `services/session-command-host.ts`:`applySessionCommandAsHost` / `readSessionRowAsHost` / `isOccupancyHeld`,补心跳回落探针、并在 commit 后做 close 释放的 dashboard 图片目录清理(与 daemon 的 close 后清理同一函数)。CLI 的 delete / prune / whiteboard 与 dashboard 删板解绑都走它;CLI 私有的三份字段清单删除。
-5. 沙盒 / 读隔离 CLI:daemon 不可达时 `botmux delete` 明确报错、`list` 自动 prune 与 `whiteboard` 绑定返回失败并保留行,不再尝试离线写(以前会在只读挂载上抛 SQLite 错误栈)。
-6. 有意保留的差异:宿主 close 不写 `tokenUsage`(宿主 shell 未必能解析 BOT_HOME 下的 transcript,落一个永久 `null` 会让 dashboard 停止实时计算);宿主 close 不抹 `mojoCloseJournal`(与改前离线 close 一致)。以前离线 close 额外删除的 `codexAppDispatchLedger` / `queuedActivation*` / `pendingRepoSetup` 现在与 daemon 一致地保留在已关闭行上(daemon 从未删过它们,resume 时由 `reactivateClosedSession` 清)。
-7. `abortIf` + `findOnlineDaemon` 仍是无有效租约时的回落;该回落随升级窗口关闭一并删(条件同 Stage 0 / Stage 1)。
+### 3.8 常态可见性
-未纳入本 stage:daemon 与宿主各自的运行时拆除(worker / backing 的杀法)本来就分属两种进程形态,不是行级 apply;IPC 传输层(`postSessionCliIpc` 的 capability 鉴权 vs `fetchDaemonIpc` 的 host HMAC)承载不同的鉴权语义,不合并。
+升级窗口从「自动关闭」改成「用户手动关闭」后长度没有上界,只在失败时提示等于把发现时机推给一次失败操作;而会话里 agent 背后的人根本看不到事后报错。所以这一节随前置 PR 一起做(§5 B-11):
-### Stage 3 — Per-session turn【已落地:开场激活窗口】
+- `botmux status` 增加 VERSION 列(`listOnlineDaemons` 按 appId join fleet 行),表尾提示「N 个 daemon 仍在跑 vX(磁盘 vY),运行 `botmux restart` 应用」。
+- dashboard「版本与更新」卡增加「运行中的 daemon:n 个 v3.20.0 / m 个 v3.19.3 ⇒ 需要重启」,数据源 `registry.list()`,对照量是 `currentInstalledVersion()`。版本不进 `botsRosterSignature`,靠轮询刷新。
+- `postinstall-bin.mjs` 与 `install.sh` 在升级成功后无条件打一行「若 daemon 正在运行,请执行 `botmux restart` 应用新版本」(它们读不到、也不该读 `~/.botmux/data`)。
+- dashboard 历史弹层的 `sessions.history.staleHint` 改成真判定(决策记录 2026-09-09):仅当该 bot 的 `descriptor.botmuxVersion` 存在、非 `0.0.0`、且与 `currentInstalledVersion()` 不相等时,附一句「运行中的 daemon vX 与磁盘 vY 不一致,运行 `botmux restart` 应用」;否则只显示原始 not_found。文案与版本卡共用同一个 helper,不比版本大小。dashboard 面向操作员,带 `botmux restart` 指令合法。
+
+### 3.9 会话内定位:先问活着的 daemon,问不到再读库
+
+`botmux send`、`detectCurrentSession`、`currentWhiteboardContext`、`resolveSessionAppId` 今天都用 `loadSessions().get(sid)` 从磁盘上的会话库定位自己,即使 daemon 正活着。这是「文件即权威」时代的遗留:daemon 在时,它内存里的行才是权威(§0 原则 4),跨进程读库只该发生在 daemon 不在时。改法:
+
+1. 新增一个解析器 `resolveSessionById(sid)`,替换上述入口里的 `loadSessions().get(sid)`:
+ - 先定位 bot:env `BOTMUX_LARK_APP_ID`(worker 注入,`worker.ts:1249`);没有时按在线 descriptor 逐个询问。
+ - 向该 bot 的 daemon 发 `GET /api/sessions/:id`(`dashboard-ipc-server.ts:1189`,2026-04-30 起存在,早于第一个发布 tag;宿主 HMAC 鉴权,2026-07-16 起所有 v3.x daemon 都认)。200 → 用它返回的行;**404 来自已应答的 daemon,是权威的「不存在」**,不再回落读库;连接失败 / 无 descriptor / 本进程读不到 secret(隔离 CLI)→ 读库(含 §3.3 的 unmigrated 判别)。
+ - 返回行的 `larkAppId` 必须与 env 一致、`sessionId` 必须等于请求的 id,否则拒绝。descriptor 只用于寻址(§0 原则 7):伪造 descriptor 至多让请求打到一个假端口,返回的行过不了这两条核对。
+2. daemon 侧把路由返回补全为 send 需要的字段:当前轮的 `replyTargets` 条目、`currentReplyTarget`、`quoteTargetId` / `quoteTargetSenderOpenId`、`codexAppDispatchLedger`。旧 daemon 只返回基础行(`sessionId` / `larkAppId` / `chatId` / `chatType` / `rootMessageId` / `scope` / `status`),send 据此降级:无每轮锚点时回到话题根,`--mention-back` 退回会话级发送者。
+3. `botmux list` 仍是快照,但组装方式改成 dashboard 已经在用的那种:在线 daemon 走 `GET /api/sessions`,只对离线 bot 读库。这一步不在收尾 PR 里(§5 C-16 的枚举器收敛时一起做)。
+4. 沙盒:Linux bwrap 的 send 由宿主侧 re-exec 完成,能读 secret,走 daemon 优先;macOS 凭证隔离的 CLI 读不到 secret,保持读库。不为它新开一条免鉴权的读路由——那等于把会话路由暴露给任何本机进程。
+
+效果:正常状态下 send 不再依赖跨进程读库,会话库的跨进程读只剩「daemon 不在」这一种情形;旧 daemon 窗口里的 send 从「未找到 session」变成降级可达。这不是兼容分支:它不读任何旧格式,走的是首发就有的正式通道,窗口关闭后仍是主路径。
+
+## 4. 后续 stage
+
+### Stage 0 — 删除 daemon JSON 写路径【#1051 已合入;收尾:删除跨进程 JSON 读写】
+
+**删除条件已满足**(按 §3 的策略,不再等 fleet 自动重启,也不设复核日期)。收尾范围见 §5 A-1。
+
+删除后保留的 JSON 认知只有两处:owner daemon 首次 `load()` 的一次性导入(含文件锁、中毒库恢复),以及 §3.3 的 `unmigrated` 判别(只看文件是否存在)。导入的删除条件见 §5 C-15。
+
+### Stage 1 — Occupancy 写入 SQLite【已落地;收尾:daemon 侧不变量 + 心跳定性】
+
+已落地内容见 2.2。收尾:
+
+1. 心跳探针按 §3.4 改语义与返回值,不删除。原文「删除该回落的条件与 Stage 0 相同」作废。
+2. daemon 侧不变量按 §3.7。
+3. `occupancy` 行增加 `owner_start_identity`(显式 ALTER,须能在 v3.18.x 建出的库上安全运行),之后宿主侧 `processAlive` 判定改用它,消除 PID 复用误拒。
-**目标**:同一 `sessionId` 上的命令在跨 `await` 后仍串行。按 session 排队,不引入 `SessionRuntime`。
+### Stage 2 — 单一 apply 路径【已落地;缺口:lineage 事务收口】
-已落地:
+已落地内容见 2.2。类型化命令的适用范围(决策记录 2026-09-09):**只用于跨进程边界的转换,或多字段必须原子成立且有多个写者的转换**。close / prune / whiteboard / worker-exited 属前者,close / reactivate / mojo journal 属后者;daemon 内部的 activity bump / pid / 流式卡片状态两者都不是,保持就地改、行级落盘。
-1. `core/session-turn-queue.ts#runSessionTurn(sessionId, command)`:按 `sessionId` 的 Promise 链,命令按入队顺序执行、跨自身 `await` 不交错,前一条失败不阻塞后一条;链空即回收。没有 mailbox 类型、没有 actor 对象。`hasPendingSessionTurns(sessionId)` 供入口判定「这条 session 上还有命令没跑完」。
-2. 收进队列的命令,全部在**开场激活窗口**——到达 → 构造 prompt(`await` 发送者查询)→ 落 durable tail:
- - 同 anchor 后到消息的两处入口(`initialStartPending` 下的 follower;worker 已死、带 retained journal 的 refork 前置 staging)统一走 `admitFollowerBehindOpening`:到达时同步取 FIFO 序号,构造 + 落盘作为一条命令入队。若开场的释放在它之前跑完(tail 为空、路由已放开),同一条命令内联 promote,不再排队。pendingRepo 分支从到达到落盘没有 `await`,不需入队。
- - 开场的路由释放 `releaseQueuedActivationReservation`(worker `queued_activation_submitted` ACK、普通冷 fork 后的交接、失败重试)入队,因此必然在先到的 follower 落盘之后执行。
- - `hasQueuedActivationAdmissionGate` 用 `hasPendingSessionTurns` 代替计数:队列上有命令时,live worker 的普通 turn 也进 durable tail,不得插队。
-3. 删除的分散 fence:`queuedActivationTailAdmissionsOutstanding` 计数、`queuedActivationTailReleasePending` 延迟交接、`reserveAsync… / settleAsync…` 回放;`forkReservedInitialSession` 与原始命令冷启动对计数的判定改为查队列。保留的 100ms 重试定时器只负责「promote 落盘 / IPC 失败后重试」,不再承担排序。
-4. 顺手删除的死代码:`pendingQueuedActivationFollowUps` 与 `reparkQueuedActivationFollowUpTail`——#597 的 durable tail 落地后没有任何写入方,repark 恒返回 false。
+仍未纳入且必须写明结束条件的:
-有意保留、不入队的:
+1. `persistActiveRemoteLineage*` 三函数(`session-store.ts:1435/1498/2203`)是第二套手写「`BEGIN IMMEDIATE` → 读新鲜行 → CAS 前检 → UPDATE → 回读」。收口为与宿主路径 `runUnownedRowTxn` 共用的一个事务原语(读新鲜行 → 变换 → CAS 写回),不强求进 `SessionRowCommand` 联合;删除 `'postrename_ambiguity'` 命名。结束条件:`session-store.ts` 里手写的行级事务只剩这一个原语。
+2. `whiteboard-store#unbindSessionWhiteboard`:daemon 已应答(任何状态码)即终态,不再落到宿主 apply(收尾 PR A-5)。
-- `initialStartClaimToken` / `initialStartPending`:fork 边界的所有权,跨越 `handleNewTopic` 从资源准备到 fork 的整段异步(秒级)。期间到达的 follower 必须**立刻**落 durable tail 才扛得住 daemon 崩溃;把整段准备做成队列命令会让 follower 在内存里等待、失去这层持久化。它是状态,不是 `await` 间隙。
-- `pendingRepo` 等待期的 `pendingFollowUps*` 缓冲:等人点卡片,队列不能被人拿着。
-- `admitQueuedActivationTail` / `promoteQueuedActivationTail` 里「store 外备份再回滚」的写法:删除条件不变——daemon 侧出现「按命令更新且不替换 `ds.session` 引用」的 apply 入口(`closeSession` 已是该形态)。让 admit 走它需要新的 store 导出,并改动多个 stub 了 `updateSession` 的测试夹具;本轮未做,与 promote 的四份备份一起处理。
-- worker generation / exit 路径上的 `updateSession`:没有复现证据(相关回归测试覆盖的是重启协调器)。按本节「没有复现的路径不改」不动。
+结束条件里**不包含**「`updateSession` 变 internal / 对外只暴露命令入口」。非 owner 进程只能施加 `HostSessionCommand` 这条边界已由 tsc 成立,这就是 Stage 2 在进程边界上的全部要求。
+
+### Stage 3 — Per-session turn【已落地:开场激活窗口】
+
+已落地内容不变(`runSessionTurn`、`admitFollowerBehindOpening`、`releaseQueuedActivationReservation` 入队、`hasQueuedActivationAdmissionGate` 用 `hasPendingSessionTurns`、删除分散计数 / 延迟交接 / 回放)。
+
+有意保留、不入队的(理由不变):`initialStartClaimToken` / `initialStartPending` 是 fork 边界的所有权状态,输家立刻落 durable tail 而不是排队;`pendingRepo` 等待期的缓冲;generation / exit 路径上的 `updateSession`(无复现证据不改)。
+
+残留(按 §5 D 项排期):
+
+1. `admitQueuedActivationTail` / `promoteQueuedActivationTail` 改成 `closeSession` 已有的副本形态:session-store 内部抽一个 helper(`next` 副本 → 变换 → `persistRow(next)` → `Object.assign(session, next)` 保别名),三处共用;**不新增命令类型**(决策记录 2026-09-09)。promote 依赖的 `acceptCodexAppDispatch` 不纯(读 bot 配置与 delivery sink),ledger entry 在变换外算好再传入。做完后 5 份 store 外备份与 `queued` 回滚遗漏一起消失。测试面:4 个文件必改(`session-lifecycle-start` 的字面量工厂只列 6 个导出、`daemon-rename-route` 与 `daemon-ordinary-ingress-failure-notice` 把 `updateSession` 换成内存 Map、`trigger-session-root-message` 手抄了实现),helper 内部仍走 `updateSession` 可把前三个文件的改动降到零。
+2. `initialStartClaimToken` 的 4 处裸清除改为调用带 token 的导出入口;`worker-pool.ts:11584-11585` 在 daemon 内不可达,删除。搭车在上一项。
+3. `hasQueuedActivationAdmissionGate` 的两处用户可见拒绝(`daemon.ts:19886` passthrough、`trigger-session.ts:1191` HTTP trigger)改为排进 durable tail 或等待队列排空,而不是回一条提示。
+4. `serializeByAnchor` 并入 `runSessionTurn`:键从 anchor 换成 sessionId,去掉 5s 放弃排序。这是 §1 点名要被替换的「FIFO」。
+5. `bridgeMarkerCleanupFences` 移出 `session-store`(旁路存储独立生命周期,§0.5)。
+6. `hasPendingSessionTurns` 的自锁约束改成结构性的(例如命令体内拿到的句柄不提供再入队),或至少在 `runSessionTurn` 内检测同 session 重入并抛错。
后续再有证据的交错路径,用 `runSessionTurn` 包住那一段即可,不再新增计数或标志。
### Stage 4 — (低优先级)按 session 隔离激活
-**暂不立项。** 触发条件是「同一 bot 进程容纳全部会话导致事件循环或崩溃域不可接受」,目前没有证据:2026-08-23 的恢复风暴发生在共享 tmux server 层(见 `test/tmux-startup-storm-recovery.test.ts`),按 session 拆进程解决不了它。Stage 0–3 不依赖本 stage;调度仍在本机,不引入跨机器放置。只有出现上述证据时再评估。
+**暂不立项。** 触发条件与理由不变:目前没有「同一 bot 进程容纳全部会话导致事件循环或崩溃域不可接受」的证据;2026-08-23 的恢复风暴发生在共享 tmux server 层。
### 不做
- 合入 #831,或任何只把部分写点迁入新层、旧路径完整保留的 runtime。
- 把旁路文件并入会话库。
- 为 actor 引入 BotId 分配或注册表。
-- 把跨进程 JSON 读取写成长期不变量。
+- 为升级窗口保留跨进程 JSON 读写、或任何「新代码读旧格式」的分支。
+- 用 descriptor 或版本号比较作为放行依据。
+- 把 daemon 内部的 `updateSession` 收成命令联合(ActivityBumpCommand / SetPidCommand 一类)。它消不掉任何真实故障,per-field 命令没有 tsc 能守的不变量,138 处的迁移必然长期两套写法并存——那正是 #831 的形状,对以 agent 为主的改码者更糟:agent 就近复制模式,两套并存会被无限延续。
- 以「本 PR 净行数未减少」否决朝终态收敛的改动。
-## 4. #1051 与终态的关系
+## 5. 收尾分解(PR 粒度)
-**#1051 已合入 master。** Stage 0 主体完成;不要回头在后续 PR 里把 occupancy 和 JSON 读路径删除搅在一起。
+### A. 收尾 PR(一次装下;彼此耦合,拆开会留半套协议)
-已覆盖:
+1. **S** 删 db-else-json:`StoreFileRef.kind`(11 处,含 `session-store.ts:1700` 内联字面量)、`resolveStoreFile` / `listStoreRefs` 的 JSON 登记、`readStoreEntries` / `readStoreRowByKey` / `readStoreActiveRows` / `countActiveSessionsOnDisk` 的 JSON 分支、`getSessionFresh` 的文件锁读、`loadFromFrozenJson`、`runUnownedRowTxn` 的 JSON 写分支、`fs-policy.ts` 两处授权、`worker.ts:19228` 注释。`readOccupancyLease` 简化为 `existsSync(dbPath)`。`writeFileSync` import 随之无调用方。`stripLegacyPendingCardFields` / `parseSessionsProjectionStrict` / `storeJsonFileName` / `isTransientStoreContentionError` 仍被导入用,**不会变死**。**排序约束**:JSON 写分支里 `abortIf` 是唯一的所有权判定,删 `abortIf` 必须与删 JSON 写分支同 PR 或在其之后。
+2. **S** `abortIf` → `probeHolder` 枚举(§3.4),删 `session-store.ts:2625` 第二次判定,`isOccupancyHeld` 返回枚举,`UnownedRowBlocked.owned` 带 `heldBy`。
+3. **M** `unmigrated` 三态 + 共享文案 + 三个落点 + `deleteWhiteboard` reason 通道(§3.3);`loadFailure` 下 `getSession` / `getSessionFresh` 改为抛而不是跨 store 静默回落(worker 侧 fail closed 才完整)。
+4. **S** `defaultIsSessionActive` 改读 store:`readSessionRowCopiesAcrossStores` 的返回扩成 `{ matches, unreadableStores }`;任一 `active` → `true`,无命中且有不可读 store 或 `SessionStoreSqliteUnavailableError` → `undefined`,否则 `false`;透传 `deps.dataDir`。
+5. **S** 隔离判定前移(§3.6);`whiteboard-store` 的「已应答即终态」。
+6. **S** 死代码与过时注释:`__testOnly_setAfterRemoteBatchRename`、`test/session-store.test.ts:25` 的 fs mock 注入、`daemon.ts:4212-4214`、`dashboard-ipc-server.ts:2247`。
+7. **S** 测试改造,按 §6。
+8. **M** 删扁平 legacy store(**独立 PR,与 A+B 同时开、紧随其后合**;决策记录 2026-09-09):`init(appId: string)` 必填,删 8 处支持点与 `hostOptions` 的无 `larkAppId` 分支、`cli.ts:3877-3882` 的离线路径特例。实际改动面约 20 个测试文件、100+ 处无参 `init()`(`session-store.test.ts` 42、`dashboard-ipc.test.ts` 22、`restore-zombie-close.test.ts` 12,其余多为 1 处),全是机械补 appId。无 `larkAppId` 的行已决定放弃,PR 描述里写明。它与 A-1 之后的代码没有耦合;拆出去是为了让 A-1 / A-2 / A-9 的协议删除在审查时不被夹具改写淹没。
+9. **M** 会话内定位改为 daemon 优先(§3.9):`resolveSessionById` 替换 `cmdSend` / `detectCurrentSession` / `currentWhiteboardContext` / `resolveSessionAppId` 里的 `loadSessions().get(sid)`;daemon 侧 `GET /api/sessions/:id` 补全路由字段;返回行与 env 的一致性核对;测试补「daemon 应答 200 / 404 / 连接失败」三种分支与 larkAppId 不一致拒绝。
-- 删除 daemon 的 JSON 写路径(整图 `save()`、JSON CAS、运行时 JSON 迁移写入)。这是换引擎之后减少协议种类最多的一块。
-- 行级 upsert,不再用陈旧整图覆盖已提交行。
-- 导入:暂存库使用 `journal_mode=DELETE`(避免 bun:sqlite 在 WAL 下 `rename` 出只有文件头的库)、按文件 key 插入而不按行内 `sessionId` 重键、`owner: false` 只读、不因探测路径创建空库而跳过导入。
-- close 路径先写 SQLite 再合并回内存对象,与单一 apply 方向一致,保留。
-- 测试夹具写入真实 SQLite,不再靠写 JSON 让「唯一写入入口」测试误绿。
-- 删除白板:daemon 运行中 IPC 解绑,未运行时离线写,两侧都对板 id 比对;无法解绑的会话计入返回。sqlite 离线写打开前拒绝缺文件。
-- 离线写与 daemon 发现各收敛成一份实现(见 §3 Stage 0 缺口)。Stage 1 / Stage 2 的删除面因此只剩一个入口。
+### B. 必须同 PR 或紧邻的前置 PR(否则 A-2 是回归)
-#1051 明确没做、也不该做的(现仍成立):
+8. **M** descriptor 加 `sessionStoreProtocol` 与 `botmuxVersion`,读侧两处同步(§3.5);停写 descriptor 的 `supervisorShutdownProtocol` 字段,删 dashboard 前端 `bootstrapRequired` 死分支及其 i18n、`cli.ts` 的 `RestartLifecycleFlags`;关停协议常量与关停状态机不动,合同测试改为不再断言该字段。
+9. **M** supervisor `killTimeoutMs` 对齐关停预算;`shutdown-budgets.ts:53-55` 断言改对象(§3.7-5)。
+10. **M** 宿主侧租约有效性两段规则(§3.4)。
+11. **S** 常态可见性(§3.8,依赖 B-8 的 `botmuxVersion`):`botmux status` 的 VERSION 列与表尾提示、dashboard「版本与更新」卡的「运行中的 daemon 需要重启」、`postinstall-bin.mjs` 与 `install.sh` 升级后的一行提示;历史弹层 `staleHint` 改为基于 `botmuxVersion` 的真判定(§3.8)。
-- 跨进程改为只读 SQLite。在升级窗口关闭前删除 JSON 回落,会让窗口内的 `botmux send` 失败。删除条件见 Stage 0。
+### C. 单独排期
-合入 #1051 后的状态:daemon 只写 SQLite;其它进程在升级窗口内仍可能读 JSON。Stage 1 之后:occupancy 在同库租约。Stage 2 之后:行级 apply 只有 `session-commands.ts` 一份,非 owner 进程只能施加命令。之后按 Stage 3 推进。
+11. **M** daemon 侧租约状态机 + `persistRow` 断言 + 关停期续租检查(§3.7-1/2/3/4),含只读库 fail-fast(已决定)。
+12. **L** occupancy 接进 daemon runtime:restore / fork / tmux adopt 的 boot gate(§3.7-6)。这是 §1「至多一个激活」第一次真正成立。
+13. **L** `occupancy` 加 `owner_start_identity` 列 + 宿主侧改用它(Stage 1-3)。
+14. **M** `persistActiveRemoteLineage*` 与宿主路径共用一个事务原语(Stage 2-1);`bridgeMarkerCleanupFences` 移出 session-store(Stage 3-5)。
+15. **M** 删除一次性导入、其文件锁与中毒库恢复(约 400 行)以及 `frozenJsonRows` 一族测试夹具;届时 `storeJsonFileName` 只剩 §3.3 的 `unmigrated` 判别一个用途,`utils/file-lock.ts` 对会话库的依赖整体解除。**条件(已决定)**:latest ≥ v3.19 满 90 天(2026-12-06 之后),且对线上 `session-stores/` 做一次 `find … -name '*.tmp*'` 孤儿核查为零。
+16. **M** 6 个跨 store 枚举入口(顺带把 `botmux list` 改成「在线 daemon 走 IPC、离线 bot 读库」,§3.9-3)(`findInOtherFiles` / `countActiveSessionsOnDisk` / `collectBotmuxSessionIdentities` / `loadAllSessionsSnapshot` / `readSessionRowCopiesAcrossStores` / `findActiveSessionsMatching`)收成一个带显式失败策略的枚举器 + 一个投影,并借此给跨 bot 读加「该 store 不可读 / 未迁移」的返回通道。
+17. **M** Stage 3 残留 1–3(admit / promote 改为副本形态、claim token 清除、gate 的两处用户可见拒绝)。
+18. **L** Stage 3 残留 4(`serializeByAnchor` 并入 `runSessionTurn`)。
-## 5. 建议顺序
+## 6. 测试面收缩
-```
-#1051 合入 Stage 1 落地 删除条件满足 单一 apply turn
- | | (fleet 落地 / 复核点) | |
- +-- Stage 0 ----+-- Stage 0 收尾 -----| | |
- | JSON 写删除 | 删 JSON 读 / 缺行回落 | |
- +-- Stage 1 ----+----------------------+-- Stage 2 ----+-- Stage 3 ---|
- occupancy
-```
+以下按逐条读过正文后的分类,行号以 master@0aba0fdd 为准。测试总数:`session-store.test.ts` 98 个 `it`(运行时 106)、`session-store-sqlite.test.ts` 21、`session-store-sqlite-poisoned-recovery.test.ts` 18、`session-occupancy.test.ts` 18、`session-delete-cli.test.ts` 14、`whiteboard-unbind-session.test.ts` 6。Stage 3 删除的六个标识符在 `test/` 与 `src/` 零残留;`abortIf` 的测试面是 3 个文件 13 处。
+
+### 6.1 直接删除(被测对象随收尾 PR 消失,或今天就已恒真)
+
+| 用例 | 理由 |
+|---|---|
+| `session-store.test.ts:273` keeps loaded sessions available when persisting a scope repair fails | 注入永不触发(唯一 `writeFileSync` 在 JSON 离线写,导入走 `renameSync`),断言恒真 |
+| `session-store.test.ts:1442` should handle atomic writes (tmp file rename) | 断言 `sessions.json.tmp` 不存在,全仓无代码创建它 |
+| `session-store.test.ts:1630` a frozen pre-SQLite JSON is not a second copy | 与 `session-store-sqlite.test.ts:225` 重复;保留后者作为「冻结 JSON 不是 store」的唯一护栏 |
+| `session-store.test.ts:1726` re-checks abortIf immediately before publication | 第二次判定随 `abortIf` 一起删除 |
+| `session-store.test.ts:1782` contended when the JSON store file lock is held | JSON 文件锁来源消失;SQLite 侧的 contended 由 `session-store-sqlite.test.ts:337` 覆盖 |
+| `session-store-sqlite.test.ts:281` offline mutation targets the .db and leaves the frozen JSON untouched | 前半与 `:323` 重复,后半恒真 |
+| `session-store-sqlite.test.ts:298` keeps the abortIf entry + pre-publication probes | `expect(probes).toBe(2)` 没有可保留的一半 |
+| `session-store-sqlite.test.ts:535` an un-imported peer store still resolves for the identity scan | 与 `:249` 同场景;`:249` 改写后保留 `collectBotmuxSessionIdentities` 的唯一真实覆盖 |
+| `session-occupancy.test.ts:353-377` describe「JSON upgrade-window path still uses abortIf」 | 整段随 JSON 写路径删除;文件头 4-7 行的升级窗口注释一并撤 |
+
+### 6.2 改写为终态断言
+
+| 用例 | 终态形态 |
+|---|---|
+| `session-store.test.ts:1578` still reads a store whose owning daemon has not imported it yet | 同夹具(只有 `sessions-appB.json`)下快照不含 b1、`unmigratedAppIds` 含 appB,点读 / 命令入口得到 `unmigrated` |
+| `session-store.test.ts:1712` yields owned untouched when abortIf trips — for the read as well as the apply | 后半是全仓唯一「读路径也按同一所有权规则 yield owned」的覆盖。改用 `seedOccupancyLease` 播有效租约,同时断言 `applySessionCommandUnowned` 与 `readSessionRowUnowned` 都 `owned` 且行未变;迁到 `session-occupancy.test.ts` |
+| `session-store.test.ts:1766` writes the JSON store while its owning daemon has not imported it yet | 不写 JSON、不建 `.db`,返回 `unmigrated` |
+| `session-store-sqlite.test.ts:202` a non-owning process reads the JSON and never bootstraps the .db | `init('appA', { owner: false })` 无 `.db` 时 `listSessionsStrict` 抛 unmigrated,不建 `.db`、不回写 JSON;随后 owner init 仍完成导入 |
+| `session-store-sqlite.test.ts:249` an un-imported peer store still composes with imported ones | `seedJson('sessions-appOld.json')` 换成 `seedPersistedSessionRows`,四个跨 store 断言原样保留 |
+| `session-occupancy.test.ts:114` expired lease + fresh heartbeat still aborts | 断言 `owned{heldBy:'legacy_daemon'}`(descriptor 无能力位)与文案;行保持 active |
+| `session-occupancy.test.ts:142` missing lease + fresh heartbeat aborts | 同上;再补一条 descriptor 带能力位 → `daemon_without_lease` |
+| `session-occupancy.test.ts:158` isOccupancyHeld never throws — the heartbeat decides | 只保留「不可读库不抛、返回未持有」;`store_unreadable` 的文案断言可选 |
+| `session-delete-cli.test.ts:613` closes a session whose owning daemon has not imported the store yet | exit 1,stderr 含 unmigrated 文案(不是「没有活跃会话」),JSON 不变,不生成 `.db` |
+| `whiteboard-unbind-session.test.ts:131` unresolved when a daemon is visible but IPC fails | 同时 seed 有效租约 + 可见 descriptor + IPC 抛错 → `unresolved:1`、`whiteboardId` 保持;再补「daemon 应答 500 → 不回落宿主写」与「descriptor 新鲜但无租约 → unresolved 并带 reason」 |
+| `mojo-containment.test.ts:422` defaultIsSessionActive is genuinely tri-state | 用 `seedPersistedSessionRows` 播 SQLite:active → true、closed → false、不存在 → false、库存在但打不开 / 引擎不可用 → undefined;残留 `sessions-*.json` 不影响任何答案 |
+| `fs-policy.test.ts:313`、`:506` sessions-cli_self.json readOnly | 改 `'none'`;`:1653`(no-transport)改 `'deny'`(与同段 `:1655` 同源)。三处 `310-312` / `504-505` / `1651-1652` 的注释已经写着「不再授权」,与断言矛盾,一并修正 |
+
+### 6.3 只改名 / 注释(断言在终态原样成立)
+
+- `session-store.test.ts:1315`「should use legacy sessions.json when no appId is set」实际断言的是扁平 `sessions.db`;`:1570` / `:1753` 的「导入门」理由改成「空库会遮蔽 unmigrated 判定」;`:526` 末句 `sessions-app-A.json` 不存在的断言恒真,改断 per-bot `.db` 不被创建。
+- `session-occupancy.test.ts:100` / `:128` 名字里的 heartbeat 子句与 `writeDaemonHeartbeat` setup 对结果无影响,摘掉。
+- `session-delete-cli.test.ts:373` 名字里的「no heartbeat is fresh」从一开始就与用例体(没写 descriptor)不符。
+
+### 6.4 保留(含删除条件)
+
+- 导入与中毒库恢复用例(`session-store.test.ts:172/195/227/415/425/438/445/526/1322/1423/1453/1479`、`session-store-sqlite.test.ts:94-181`、`session-store-sqlite-poisoned-recovery.test.ts` 全部 18 条)随 §5 C-15 一起删除。`frozenJsonRows` 夹具在 poisoned-recovery 里有 10 处引用(`60/333/363/388/405/426/464/481/499/584`)。
+- `session-store-sqlite.test.ts:225`(冻结 JSON 不是 store)、`:323`(改新鲜行)、`:337`(SQLITE_BUSY → contended)是终态保证,保留。
+- `session-delete-cli.test.ts:312`(daemon 拒绝不因过期租约翻成离线写许可)、`:352`(无 descriptor 但有租约必须让位)、`:438` / `:462`(沙盒与 origin-channel 闸)是终态核心。
+- `initial-user-turn-opening.test.ts`(28 条,运行时 38)与 `session-turn-queue.test.ts`(5 条)没有一条在钉 Stage 3 之前的排序机制,全部保留。
+
+### 6.5 夹具缺陷
+
+- `trigger-session-root-message.test.ts:72-74` 手抄的 gate 缺 `hasPendingSessionTurns`,已与生产分叉;`:184-187` 手抄了 admit 的写法。改为从生产模块取。
+- `session-resume.test.ts:61/182/200-201/1158/1170` 与 `dashboard-create-session.test.ts:87` 把 `promoteQueuedActivationTail` 整个 mock 掉,改入口不会变红但也不验证任何真实行为。
+- 本机 `session-store-sqlite-poisoned-recovery.test.ts` 因 bun 1.4.1 ≠ 钉住的 1.4.2 全部失败,属环境性失败,与分类无关。
+
+## 7. 减负候选(面向终态,暂不立项)
+
+- 「当前版本」有四份解析实现(`install-info.botmuxVersion`、`install-diagnostics.resolveCurrentVersionAt`、`version-info.resolveEffectiveBotmuxVersion`、`cli.ts#getVersion`),后两者的 git describe 逻辑逐字重复。descriptor 只写 `install-info` 那份。
+- `lastCliInput` 在 `ds.lastCliInput` 与 `ds.session.lastCliInput` 双镜像维护(`session-manager.ts:1967-1980`,`daemon.ts:21041` 用 `??` 兜底);行已在 SQLite 后内存镜像是冗余。
+- 两套一次性认领并存:`initialUserTurnPending`(`core/initial-user-turn.ts` 同步 RMW + 落盘)与 `initialStartClaimToken`;admit / promote 命令化时可把 `claimInitialUserTurn` 一并包进 `runSessionTurn`。
+- `queuedActivationTailReleaseRetryTimer` 的 100ms 定时器只剩「promote 落盘 / IPC 失败重试」一种职责,可做成队列上的一条命令。
+- `session-command-host.ts` 在 A-2 之后只剩「转发 + close 后清理」,可并回 `session-store` 的导出面。
+- fleet-state 里有每个子进程的 pid / generation,supervisor 在 spawn 时记录自己的 dist 身份比 descriptor 更早知道「谁在跑什么版本」,但只在 supervisor 自己也重启后才准,只能作为 §3.8 的补充信息源。
-- **#1051**:删除 daemon JSON 写路径;含白板解绑的 compare-and-set、离线写打开前拒绝缺文件、离线写与 daemon 发现各收敛成一份实现。
-- **Stage 1**:occupancy 写入 SQLite;有效租约一票否决,`findOnlineDaemon` 降为无有效租约时的回落。回落的删除条件见上。
-- **删除 JSON 读路径**:升级窗口已按关闭处理(见 Stage 0 的状态记录),与 Stage 1 心跳回落一起进下面的收尾 PR。
-- **Stage 2(已落地)**:daemon 未运行时宿主在同一事务内执行同一 apply,删除第二套对外写协议(任意闭包改行)。这一阶段减少的概念最多。
-- **Stage 3(已落地)**:开场激活窗口内的命令收进按 `sessionId` 的队列;计数 / 延迟交接 / 回放删除。不设「迁完全部写点」的完成门。
-- **下一个 PR:Stage 0 / Stage 1 收尾(净删除)**,升级窗口已按关闭处理(见 Stage 0 状态):
- - `session-store`:删 db-else-json 分流(`StoreFileRef.kind`、`resolveStoreFile` / `listStoreRefs` 的 JSON 分支、`readStoreEntries` / `readStoreRowByKey` / `readStoreActiveRows` / `countActiveSessionsOnDisk` 的 JSON 分支)、`getSessionFresh` 的 JSON 文件锁读、非 owner 进程无库时的 `loadFromFrozenJson`、宿主命令的 JSON 文件锁写路径。无 `.db` 且尚未导入时非 owner 进程明确报「会话库尚未迁移,请重启 daemon」。
- - 删 `abortIf` 心跳回落:`session-command-host` 的 `legacyHeartbeatHeld` / `hostOptions`,`isOccupancyHeld` 只看租约;`sqliteOccupancyBlocksWrite` 去掉 `abortIf`。
- - 沙盒 `fs-policy`:不再授权 `sessions-.json` 只读(两处)。
- - `core/mojo-containment-command.ts#defaultIsSessionActive` 仍直接扫 `sessions*.json`:SQLite 后它对所有会话都答「不活跃」,revoke 的安全闸静默失效。改为读 store(严格跨 store 点读,保留「有库读不了 → 未知」的三态)。
- - 保留:owner daemon 首次 load 的一次性导入及其文件锁、中毒库恢复。它不是跨进程协议;删掉会让从 3.17.x 直接升到新版的用户静默丢会话。其删除条件是「升级来源不可能低于 3.18.0」,晚于本次。
- - 测试:`session-store` / `session-store-sqlite` / `session-occupancy` / `session-delete-cli` / `whiteboard-unbind-session` / `fs-policy` / `mojo-containment` 里的 JSON 窗口与心跳回落用例改为 fail-closed 断言。
+## 8. 历史
-`closeSession` 的字段级回滚已在 #1051 替换;async tail-admission 已在 Stage 3 收进队列。`admitQueuedActivationTail` / `promoteQueuedActivationTail` 的回滚写法与 generation / exit 上无保护的写入仍归 Stage 3 的后续(见其「有意保留」)。`initial-user-turn` 在落盘失败时仅更新内存:有复现再进入 Stage 2 或 3,不单独开事务修复轨道。
+2026-08 曾用 `SessionRuntime` 包装会话写入(#831)。按调用方群组迁移导致新旧路径长期并存,大部分写点未迁入,适配层按设计要整段删除,审计脚本挂在 build 上。**不合入。** 从中保留并已落地的是:会话行唯一写入入口(#846)、JSON 换成 SQLite(#852)。
-## 6. 历史
+2026-08-28 至 09-08:#1051 删除 daemon 侧 JSON 写路径并把落盘改成行级 upsert,同时把删板解绑、离线写与 daemon 发现各收敛成一份实现;#1202 落地库内租约;#1280 落地单一 apply 并删除「任意闭包改行」的离线写入口;#1308 把开场激活窗口的串行化收进 `runSessionTurn`。期间跨进程读写按「升级窗口无上界」保留了 db-else-json 与心跳回落,删除条件曾定为「fleet 自动重启落地或 2026-11-26 复核」。
-2026-08 曾用 `SessionRuntime` 包装会话写入(#831)。按调用方群组迁移导致新旧路径长期并存,大部分写点未迁入,适配层按设计要整段删除,审计脚本挂在 build 上。**不合入。** 从中保留并已落地的是:会话行唯一写入入口(#846)、JSON 换成 SQLite(#852)。当时记录的多数「缺陷」是那次包装自己引入的回归,不作为现行证据。
+2026-09-08 起:维护者决定不再等 fleet 自动重启,升级窗口由用户手动重启关闭,新代码遇到旧 daemon 时 fail closed 并提示。据此跨进程 JSON 读写按 §5 A 净删除;心跳探针经复核不是兼容路径而是与版本无关的存活兜底,改为只拒绝、带原因,不删除(§3.4)。同日二次修订把三项待定项落定为决策(阅读指引的决策记录):扁平 legacy 行放弃并随收尾 PR 删除支持点;只读库 daemon fail-fast;导入与中毒恢复按日期 + 磁盘核查删除。三次修订:会话内定位改为 daemon 优先、库兜底(§3.9、A-9),§3.2 中「会话内 agent 回不了消息」的代价改为「降级但可达」;常态可见性从单独排期提前进前置 PR(B-11)。
-同期口径要求「每步合入必须立刻更简单、收益不得递延、先做存储且不实现 actor」。它避免了再次合入只覆盖部分写点的 runtime,也把 occupancy、单一 apply、turn 拆成互不相关的步骤。本文取代该口径。会话行已由 SQLite 持久化之后,终态是本机 virtual actor:命令、库内租约、按 session 串行;host 进程可更换;不存在第二套权威写入。
+2026-09-09 四次修订:经评审撤回「daemon 内部 updateSession 命令化」(原 Stage 2-2 / C-15)。理由:`runSessionTurn` 入队的是闭包,Stage 3 止于开场窗口是因为没有更多有复现的交错,不是缺命令类型;per-field 命令没有 tsc 能守的不变量;138 处必然长期两套并存。类型化命令的适用范围收窄为「跨进程边界」与「多字段原子且多写者」两类;Stage 2 结束条件改为「手写行级事务只剩一个原语」。同日实施侧提出三处取舍并已裁定:A-8 拆为紧随其后的独立 PR;`supervisorShutdownProtocol` 字段停写、前端死路径删除;`staleHint` 改为版本真判定。
diff --git a/install.sh b/install.sh
index 427bfe1a2f..dc9ff3bb8f 100755
--- a/install.sh
+++ b/install.sh
@@ -272,4 +272,5 @@ else
printf ' %s\n' "echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.profile && . ~/.profile"
fi
+printf '\n%s\n' "若 daemon 正在运行,请执行 botmux restart 应用新版本"
printf '\n%s\n' "Next: botmux setup"
diff --git a/scripts/postinstall-bin.mjs b/scripts/postinstall-bin.mjs
index 91849bebf6..f54add3739 100644
--- a/scripts/postinstall-bin.mjs
+++ b/scripts/postinstall-bin.mjs
@@ -386,3 +386,5 @@ try {
console.log(`[botmux] add ${binDir} to your PATH so this launcher is the \`botmux\` your shell finds`);
}
}
+
+console.log('[botmux] 若 daemon 正在运行,请执行 botmux restart 应用新版本');
diff --git a/src/adapters/cli/fs-policy.ts b/src/adapters/cli/fs-policy.ts
index 0c741e60e4..35ddec7e5e 100644
--- a/src/adapters/cli/fs-policy.ts
+++ b/src/adapters/cli/fs-policy.ts
@@ -817,13 +817,7 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// would keep reading the dead WAL forever. A directory bind resolves names
// live. Sibling bots' store dirs stay uncovered (deny-by-default).
//
- // The pre-SQLite `sessions-.json` is granted too, and stays granted
- // until the upgrade window is provably closed: while the owning daemon still
- // runs a pre-SQLite build there is no `.db` at all, and a sandboxed
- // `botmux send` that cannot even stat that file reports "session not found"
- // — i.e. the agent silently loses the ability to reply.
push([
- `${ctx.sessionDataDir}/sessions-${ctx.currentAppId}.json`,
`${ctx.sessionDataDir}/session-stores/${ctx.currentAppId}`,
], 'readOnly', 'internal');
// Own upload bucket — readWRITE: `botmux quoted` / downloadResources writes the
@@ -1001,9 +995,6 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// own-app-scoped — NOT the shared secret/port table).
push([
`${ctx.sessionDataDir}/bots-info.json`, // display names for (public-ish)
- `${ctx.sessionDataDir}/sessions-${ctx.currentAppId}.json`,
- // Own SQLite store DIRECTORY (see the larkTransport grant above for why
- // a dir, not the three files, and why the JSON is still granted).
`${ctx.sessionDataDir}/session-stores/${ctx.currentAppId}`,
`${ctx.sessionDataDir}/bot-openids-${ctx.currentAppId}.json`,
// Core-only writes its `botmux` wrapper into /bin (dedicated, NOT
diff --git a/src/cli.ts b/src/cli.ts
index 9d5def21f9..3b907df234 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -143,7 +143,6 @@ import { readSupervisorProcessStartIdentity } from './core/process-start-identit
import {
FLEET_DAEMON_EXIT_WAIT_MS,
FLEET_SUCCESSOR_SETTLE_MS,
- PM2_DAEMON_KILL_TIMEOUT_MS,
PM2_DAEMON_RESTART_DELAY_MS,
} from './core/shutdown-budgets.js';
import { describeSendFailure, dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateSlashSend, validateVideoAttachments } from './cli/send-dispatch.js';
@@ -194,6 +193,7 @@ import {
UnsupportedGlobalInstallError,
} from './utils/global-install.js';
import { isLocalDevInstall, botmuxCliEntryAt, bakedBinaryVersion, botmuxInstallRoot } from './utils/install-info.js';
+import { formatRunningDaemonsRestartSummary } from './utils/daemon-version-display.js';
import { currentUpdateStrategy, replaceStandaloneBinary } from './core/binary-self-update.js';
import { fetchLatestVersion, isNewerVersion } from './core/update-check.js';
import { resolveCurrentVersion } from './utils/install-diagnostics.js';
@@ -293,9 +293,17 @@ import {
writeManualIntentIfAbsentTo,
writeRestartAttemptIntentTo,
} from './services/restart-intent-store.js';
-import { loadAllSessionsSnapshot } from './services/session-store.js';
+import { loadAllSessionsSnapshot, type SessionsSnapshot } from './services/session-store.js';
import { sqliteEngineAvailable } from './services/sqlite-compat.js';
+import {
+ formatStoreHoldMessage,
+ formatUnmigratedMessage,
+ isSessionScopedCliProcess,
+} from './services/session-store-copy.js';
+import type { HolderReason } from './services/session-store-copy.js';
import { applySessionCommandAsHost, isOccupancyHeld, readSessionRowAsHost, type UnownedRowApply } from './services/session-command-host.js';
+import { resolveSessionById } from './cli/resolve-session-by-id.js';
+import { knownBotAppIds } from './services/known-bot-app-ids.js';
import { bindSessionWhiteboard as persistThenRememberWhiteboard, whiteboardBindFailedMessage } from './services/session-whiteboard-bind.js';
import type { HostSessionCommand } from './services/session-commands.js';
import {
@@ -2702,14 +2710,6 @@ async function cmdStop(): Promise {
}, { maxWaitMs: 5_000 });
}
-interface RestartLifecycleFlags {
- includePm2: boolean;
- includePluginServices: boolean;
- bootstrapShutdownProtocol: boolean;
- bootstrapConfirmed: boolean;
-}
-
-
async function cmdRestart(): Promise {
applyCompanionOptions(process.argv.slice(3));
const { refreshPersistedEnv, readFailureFallback } = prepareRestartDriverContext();
@@ -3193,18 +3193,24 @@ async function cmdStatus(): Promise {
console.log(' (无已配置机器人)');
return;
}
- // Fixed-width table: name | pid | status | ↺restarts | exit.
+ // Fixed-width table: name | pid | status | ↺restarts | exit | version.
+ const online = listOnlineDaemons();
+ const versionByApp = new Map(online.map(d => [d.larkAppId, d.botmuxVersion]));
const nameW = Math.max(4, ...status.rows.map(r => r.name.length));
- const header = ` ${'NAME'.padEnd(nameW)} ${'PID'.padStart(7)} ${'STATUS'.padEnd(9)} ${'↺'.padStart(4)} EXIT`;
+ const verCells = status.rows.map(r => versionByApp.get(r.appId) ?? '-');
+ const verW = Math.max(7, ...verCells.map(v => v.length));
+ const header = ` ${'NAME'.padEnd(nameW)} ${'PID'.padStart(7)} ${'STATUS'.padEnd(9)} ${'↺'.padStart(4)} ${'EXIT'.padEnd(4)} ${'VERSION'.padEnd(verW)}`;
console.log(header);
- for (const r of status.rows) {
+ for (const [i, r] of status.rows.entries()) {
// A row recorded 'online' whose pid is actually dead is shown as such so
// status never lies while the supervisor is between reconcile ticks.
const shown = r.status === 'online' && !r.alive ? 'dead?' : r.status;
const pidCol = r.pid > 0 ? String(r.pid) : '-';
const exitCol = r.lastExitCode === null ? '-' : String(r.lastExitCode);
- console.log(` ${r.name.padEnd(nameW)} ${pidCol.padStart(7)} ${shown.padEnd(9)} ${String(r.restarts).padStart(4)} ${exitCol}`);
+ console.log(` ${r.name.padEnd(nameW)} ${pidCol.padStart(7)} ${shown.padEnd(9)} ${String(r.restarts).padStart(4)} ${exitCol.padEnd(4)} ${verCells[i]!.padEnd(verW)}`);
}
+ const restartHint = formatRunningDaemonsRestartSummary(verCells.map(v => v === '-' ? undefined : v), resolveCurrentVersion());
+ if (restartHint) console.log(restartHint);
warnIfLegacyBotmuxAlive();
// The pm2 read-only projection print is gone: the fleet-state table above IS
// the authoritative status now (supervisor-owned), so there is no second
@@ -3702,13 +3708,39 @@ function resolveDataDir(): string {
/** Load sessions from all session files (legacy + per-bot). Snapshot mechanics
* live in session-store; the CLI only supplies its own data-dir resolution and
- * the sandbox fallback appId (the file sandbox exposes sessions-.json
- * but not a listing of data/). */
+ * the sandbox fallback appId. */
+let lastUnmigratedAppIds: string[] = [];
+const printedUnmigratedHints = new Set();
+
function loadSessions(): Map {
- return loadAllSessionsSnapshot({
- dataDir: resolveDataDir(),
+ const dataDir = resolveDataDir();
+ const snapshot: SessionsSnapshot = loadAllSessionsSnapshot({
+ dataDir,
fallbackAppId: process.env.BOTMUX_LARK_APP_ID,
- }) as unknown as Map;
+ knownAppIds: knownBotAppIds({ dataDir }),
+ });
+ lastUnmigratedAppIds = snapshot.unmigratedAppIds ?? [];
+ if (lastUnmigratedAppIds.length > 0 && !isSessionScopedCliProcess()) {
+ const key = [...lastUnmigratedAppIds].sort().join(',');
+ if (!printedUnmigratedHints.has(key)) {
+ printedUnmigratedHints.add(key);
+ console.error(formatUnmigratedMessage());
+ }
+ }
+ return snapshot as unknown as Map;
+}
+
+function hasUnmigratedStores(): boolean {
+ return lastUnmigratedAppIds.length > 0;
+}
+
+async function requireSessionById(sid: string): Promise {
+ const resolved = await resolveSessionById(sid, { dataDir: resolveDataDir() });
+ if (!resolved.ok) {
+ console.error(resolved.message);
+ process.exit(1);
+ }
+ return resolved.session as unknown as SessionData;
}
/** Host-side offline session commands. Callers must prefer the owning daemon
@@ -3725,9 +3757,13 @@ type OfflineRowRead =
| { ok: true; current: SessionData }
| { ok: false; error: string };
-function offlineBlockedError(outcome: 'owned' | 'missing' | 'contended'): string {
- switch (outcome) {
- case 'owned': return 'owning_daemon_became_available';
+function offlineBlockedError(blocked: { outcome: 'owned' | 'missing' | 'unmigrated' | 'contended'; heldBy?: HolderReason }): string {
+ const sessionScoped = isSessionScopedCliProcess() || isolatedCliProcess();
+ switch (blocked.outcome) {
+ case 'owned':
+ return formatStoreHoldMessage(blocked.heldBy ?? 'lease', { sessionScoped });
+ case 'unmigrated':
+ return formatUnmigratedMessage({ sessionScoped });
case 'missing': return 'session_row_missing';
case 'contended': return 'session_store_busy';
}
@@ -3737,7 +3773,7 @@ function offlineBlockedError(outcome: 'owned' | 'missing' | 'contended'): string
function readSessionOffline(session: SessionData): OfflineRowRead {
const read = readSessionRowAsHost(hostTarget(session), { dataDir: resolveDataDir() });
if (read.outcome === 'ok') return { ok: true, current: read.row as unknown as SessionData };
- return { ok: false, error: offlineBlockedError(read.outcome) };
+ return { ok: false, error: offlineBlockedError(read) };
}
function applySessionOffline(
@@ -3766,7 +3802,7 @@ const ISOLATED_CLI_OFFLINE_ERROR = '隔离会话内不能离线修改会话(da
/** Is this bot's store held by a live host (occupancy lease, or a fresh
* heartbeat while no live lease exists)? Same data dir as the store access
* above. Never throws. */
-function occupancyHeld(larkAppId: string): boolean {
+function occupancyHeld(larkAppId: string): HolderReason | undefined {
return isOccupancyHeld(larkAppId, { dataDir: resolveDataDir() });
}
@@ -3787,8 +3823,14 @@ async function abandonSessionOffline(session: SessionData): Promise {
live.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (live.length === 0) {
+ if (hasUnmigratedStores()) process.exit(1);
console.log('没有活跃会话。');
return;
}
@@ -5059,6 +5116,7 @@ async function cmdDelete(): Promise {
const active = [...sessions.values()].filter(s => s.status === 'active');
if (active.length === 0) {
+ if (hasUnmigratedStores()) process.exit(1);
console.log('没有活跃会话。');
return;
}
@@ -6214,7 +6272,9 @@ async function cmdTermLink(rest: string[]): Promise {
const target = rest[0];
const active = [...loadSessions().values()].filter(s => s.status === 'active');
if (active.length === 0) {
- console.error('没有活跃会话。可操作终端只能对 status=active 的会话获取(botmux list 查看)。');
+ if (!hasUnmigratedStores()) {
+ console.error('没有活跃会话。可操作终端只能对 status=active 的会话获取(botmux list 查看)。');
+ }
process.exit(1);
}
@@ -6562,13 +6622,13 @@ interface CurrentSession {
ownerOpenId?: string;
}
-/** Detect current session info from ancestor marker + session files. */
-function detectCurrentSession(): CurrentSession | null {
+/** Detect current session info from ancestor marker + live daemon / store. */
+async function detectCurrentSession(): Promise {
const sid = findAncestorSessionId();
if (!sid) return null;
- const sessions = loadSessions();
- const s = sessions.get(sid);
- if (!s) return null;
+ const resolved = await resolveSessionById(sid, { dataDir: resolveDataDir() });
+ if (!resolved.ok) return null;
+ const s = resolved.session;
return {
sessionId: s.sessionId,
chatId: s.chatId,
@@ -6765,11 +6825,13 @@ function readStdinUtf8(): string {
try { return decodeStdinBytes(readFileSync(0)); } catch { return ''; }
}
-function currentWhiteboardContext(args: string[]): { session?: SessionData; larkAppId?: string; chatId?: string; workingDir?: string; sessionId?: string } {
+async function currentWhiteboardContext(args: string[]): Promise<{ session?: SessionData; larkAppId?: string; chatId?: string; workingDir?: string; sessionId?: string }> {
const sessionIdArg = argValue(args, '--session-id');
- const sessions = loadSessions();
const sid = sessionIdArg || findAncestorSessionId() || undefined;
- const session = sid ? sessions.get(sid) : undefined;
+ const resolved = sid
+ ? await resolveSessionById(sid, { dataDir: resolveDataDir() })
+ : undefined;
+ const session = resolved?.ok ? resolved.session as unknown as SessionData : undefined;
return {
session,
sessionId: session?.sessionId ?? sid,
@@ -6883,7 +6945,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
console.log(JSON.stringify({ enabled: true, current: meta, path: whiteboardPath(id) }, null, 2));
return;
}
- const ctx = currentWhiteboardContext(rest);
+ const ctx = await currentWhiteboardContext(rest);
let meta = ctx.session?.whiteboardId ? getWhiteboard(ctx.session.whiteboardId) : undefined;
if (!meta && argFlag(rest, '--create')) {
meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
@@ -6901,7 +6963,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
if (action === 'create') {
requireWhiteboardEnabled();
- const ctx = currentWhiteboardContext(rest);
+ const ctx = await currentWhiteboardContext(rest);
const meta = createWhiteboard({ id: argValue(rest, '--id'), title: argValue(rest, '--title'), larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
if (ctx.session && !ctx.session.whiteboardId) {
await bindSessionWhiteboard(ctx.session, meta.id);
@@ -6921,7 +6983,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
if (['read', 'update', 'write'].includes(action)) requireWhiteboardEnabled();
const explicitId = argValue(rest, '--id');
- const ctx = currentWhiteboardContext(rest);
+ const ctx = await currentWhiteboardContext(rest);
let id = explicitId ?? ctx.session?.whiteboardId;
if (!id && whiteboardEnabled() && action === 'update') {
const meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
@@ -7020,7 +7082,7 @@ async function cmdSchedule(sub: string, rest: string[]): Promise {
// "ownerless task runs on bot-0" semantics; `list` without a bound bot
// aggregates every readable store instead.
const cliScopeAppId = argValue(rest, '--lark-app-id')
- ?? detectCurrentSession()?.larkAppId
+ ?? (await detectCurrentSession())?.larkAppId
?? process.env.BOTMUX_LARK_APP_ID;
// LAZY + sandbox-safe bots.json read: sandboxed sessions always carry a
// scope (env-injected appId) and must never touch bots.json — it is denied
@@ -7081,7 +7143,7 @@ async function cmdSchedule(sub: string, rest: string[]): Promise {
process.exit(1);
}
- const cur = detectCurrentSession();
+ const cur = await detectCurrentSession();
const chatId = argValue(rest, '--chat-id') ?? cur?.chatId;
const explicitRootMessageId = argValue(rest, '--root-msg-id');
const rootMessageId = explicitRootMessageId
@@ -7323,12 +7385,7 @@ async function resolveSessionAppId(sessionIdArg: string | undefined): Promise<{
return { sid, larkAppId: riff.session.larkAppId!, session: riff.session };
}
}
- const sessions = loadSessions();
- const s = sessions.get(sid);
- if (!s) {
- console.error(`未找到 session ${sid}`);
- process.exit(1);
- }
+ const s = await requireSessionById(sid);
if (!s.larkAppId) {
console.error(`session ${sid} 缺少 larkAppId,无法获取消息`);
process.exit(1);
@@ -8826,9 +8883,8 @@ async function cmdSend(rest: string[]): Promise {
process.exit(1);
}
- const sessions = loadSessions();
const currentTurnId = originTurnId;
- let s = sessions.get(sid);
+ let s: SessionData | undefined;
// Riff (remote backend) sandbox: no local daemon/sessions.json/bots.json.
// Fall back to env-var-only mode so `botmux send` works without a daemon.
@@ -8854,21 +8910,26 @@ async function cmdSend(rest: string[]): Promise {
}
if (!s) {
- console.error(
- '[botmux send diagnostic] session_lookup_miss'
- + ` sessionId=${sid}`
- + ` source=${sessionIdSource}`
- + ` dataDir=${sendDataDir}`
- + ` envSessionId=${process.env.BOTMUX_SESSION_ID ?? '-'}`
- + ` envLarkAppId=${process.env.BOTMUX_LARK_APP_ID ?? '-'}`
- + ` originSessionId=${originSessionId ?? '-'}`
- + ` loadedSessions=${sessions.size}`
- + ` relayDir=${relayDir ? 'present' : 'absent'}`
- + ` readIsolation=${isolatedSendRequired ? 'required' : kernelReadIsolationDetected ? 'detected' : 'off'}`
- + ` capability=${isolatedCapabilityCtx ? 'present' : 'absent'}`,
- );
- console.error(`未找到 session ${sid}`);
- process.exit(1);
+ const resolved = await resolveSessionById(sid, { dataDir: resolveDataDir() });
+ if (resolved.ok) {
+ s = resolved.session as unknown as SessionData;
+ } else {
+ console.error(
+ '[botmux send diagnostic] session_lookup_miss'
+ + ` sessionId=${sid}`
+ + ` source=${sessionIdSource}`
+ + ` dataDir=${sendDataDir}`
+ + ` envSessionId=${process.env.BOTMUX_SESSION_ID ?? '-'}`
+ + ` envLarkAppId=${process.env.BOTMUX_LARK_APP_ID ?? '-'}`
+ + ` originSessionId=${originSessionId ?? '-'}`
+ + ` reason=${resolved.reason}`
+ + ` relayDir=${relayDir ? 'present' : 'absent'}`
+ + ` readIsolation=${isolatedSendRequired ? 'required' : kernelReadIsolationDetected ? 'detected' : 'off'}`
+ + ` capability=${isolatedCapabilityCtx ? 'present' : 'absent'}`,
+ );
+ console.error(resolved.message);
+ process.exit(1);
+ }
}
if (!s.larkAppId) { console.error(`session ${sid} 缺少 larkAppId`); process.exit(1); }
const replyStyle = resolveReplyStyle(resolveReplyStyleConfig(s.larkAppId));
@@ -8963,7 +9024,10 @@ async function cmdSend(rest: string[]): Promise {
|| fresh.requiresCodexAppLedger !== isolatedManagedOriginCtx.requiresCodexAppLedger) {
throw new Error('managed origin changed before provider effect');
}
- const currentOriginSession = loadSessions().get(fresh.sessionId);
+ const currentOriginResolved = await resolveSessionById(fresh.sessionId, { dataDir: resolveDataDir() });
+ const currentOriginSession = currentOriginResolved.ok
+ ? currentOriginResolved.session as unknown as SessionData
+ : undefined;
const ledgerDecision = validateCodexAppManagedSendOrigin(
currentOriginSession?.codexAppDispatchLedger,
fresh,
@@ -11059,9 +11123,7 @@ async function cmdDispatch(rest: string[]): Promise {
console.error('无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id 。');
process.exit(1);
}
- const sessions = loadSessions();
- const s = sessions.get(sid);
- if (!s) { console.error(`未找到 session ${sid}`); process.exit(1); }
+ const s = await requireSessionById(sid);
if (!s.larkAppId) { console.error(`session ${sid} 缺少 larkAppId`); process.exit(1); }
// Target-aware gate on the RESOLVED source session: dispatch from a virtual /
// apiOnly source turn is refused even with a real --chat-id override (a
@@ -11533,9 +11595,7 @@ async function cmdReport(rest: string[]): Promise {
const currentTurnId = reportContext?.sessionId === sid
? reportContext.turnId
: undefined;
- const sessions = loadSessions();
- const s = sessions.get(sid);
- if (!s) { console.error(`未找到 session ${sid}`); process.exit(1); }
+ const s = await requireSessionById(sid);
if (!s.larkAppId) { console.error(`session ${sid} 缺少 larkAppId`); process.exit(1); }
// ── Issue Board 交付:绑定了平台 issue 的领取群 → 推 in_review(待验收)────────
diff --git a/src/cli/resolve-session-by-id.ts b/src/cli/resolve-session-by-id.ts
new file mode 100644
index 0000000000..a3d604a7a7
--- /dev/null
+++ b/src/cli/resolve-session-by-id.ts
@@ -0,0 +1,188 @@
+/**
+ * Locate one session by id: ask a live daemon first, read the store only
+ * when no daemon answered.
+ *
+ * A 404 from the OWNING daemon (the one `BOTMUX_LARK_APP_ID` names) is
+ * authoritative absence — never fall back to the store. Without an appId the
+ * resolver enumerates every online daemon; each answers only for its own bot,
+ * so a 404 there says nothing about other bots' stores and the store is still
+ * read afterwards. Connection failure / no descriptor / unread secret
+ * (isolated CLI) fall back to the snapshot, including the unmigrated probe.
+ */
+import { fetchDaemonIpc, loadDaemonIpcSecret } from '../core/daemon-ipc-auth.js';
+import {
+ classifyStorePresence,
+ loadAllSessionsSnapshot,
+ type SessionsSnapshot,
+} from '../services/session-store.js';
+import type { Session } from '../types.js';
+import { formatUnmigratedMessage, isSessionScopedCliProcess } from '../services/session-store-copy.js';
+import { knownBotAppIds } from '../services/known-bot-app-ids.js';
+import { findOnlineDaemon, listOnlineDaemons, type OnlineDaemonInfo } from '../utils/daemon-discovery.js';
+
+export type ResolveSessionByIdOk = { ok: true; session: Session; source: 'daemon' | 'store' };
+export type ResolveSessionByIdErr = {
+ ok: false;
+ reason: 'not_found' | 'unmigrated' | 'app_id_mismatch' | 'session_id_mismatch';
+ message: string;
+};
+export type ResolveSessionByIdResult = ResolveSessionByIdOk | ResolveSessionByIdErr;
+
+export type ResolveSessionByIdDeps = {
+ dataDir: string;
+ env?: NodeJS.ProcessEnv;
+ findDaemon?: typeof findOnlineDaemon;
+ listDaemons?: typeof listOnlineDaemons;
+ fetchIpc?: typeof fetchDaemonIpc;
+ loadSecret?: typeof loadDaemonIpcSecret;
+ loadSnapshot?: typeof loadAllSessionsSnapshot;
+ /** Bots that still exist (configured / online / own); leftover JSON of any
+ * other app id is abandoned data, not `unmigrated`. Computed when omitted. */
+ knownAppIds?: ReadonlySet;
+};
+
+function asSession(row: unknown): Session | undefined {
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return undefined;
+ const session = row as Session;
+ if (typeof session.sessionId !== 'string' || !session.sessionId) return undefined;
+ return session;
+}
+
+function mismatch(
+ reason: 'app_id_mismatch' | 'session_id_mismatch',
+ message: string,
+): ResolveSessionByIdErr {
+ return { ok: false, reason, message };
+}
+
+function checkReturnedRow(
+ session: Session,
+ sessionId: string,
+ envAppId: string | undefined,
+): ResolveSessionByIdErr | undefined {
+ if (session.sessionId !== sessionId) {
+ return mismatch('session_id_mismatch', `daemon 返回的 sessionId 与请求不一致`);
+ }
+ if (envAppId && session.larkAppId && session.larkAppId !== envAppId) {
+ return mismatch('app_id_mismatch', `daemon 返回的 larkAppId 与 BOTMUX_LARK_APP_ID 不一致`);
+ }
+ return undefined;
+}
+
+async function askDaemon(
+ daemon: OnlineDaemonInfo,
+ sessionId: string,
+ deps: ResolveSessionByIdDeps,
+): Promise<
+ | { status: 'ok'; session: Session }
+ | { status: 'not_found' }
+ | { status: 'unreachable' }
+ | { status: 'rejected'; err: ResolveSessionByIdErr }
+> {
+ let secret: string;
+ try {
+ secret = (deps.loadSecret ?? loadDaemonIpcSecret)();
+ } catch {
+ return { status: 'unreachable' };
+ }
+ let res: Response;
+ try {
+ res = await (deps.fetchIpc ?? fetchDaemonIpc)(
+ daemon.ipcPort,
+ `/api/sessions/${encodeURIComponent(sessionId)}`,
+ { method: 'GET' },
+ secret,
+ );
+ } catch {
+ return { status: 'unreachable' };
+ }
+ if (res.status === 404) return { status: 'not_found' };
+ if (!res.ok) return { status: 'unreachable' };
+ let body: unknown;
+ try { body = await res.json(); } catch { return { status: 'unreachable' }; }
+ const row = asSession(
+ body && typeof body === 'object' && 'session' in body
+ ? (body as { session: unknown }).session
+ : body,
+ );
+ if (!row) return { status: 'unreachable' };
+ const bad = checkReturnedRow(row, sessionId, deps.env?.BOTMUX_LARK_APP_ID);
+ if (bad) return { status: 'rejected', err: bad };
+ return { status: 'ok', session: row };
+}
+
+function readFromStore(
+ sessionId: string,
+ deps: ResolveSessionByIdDeps,
+): ResolveSessionByIdResult {
+ const env = deps.env ?? process.env;
+ const envAppId = env.BOTMUX_LARK_APP_ID;
+ if (envAppId && classifyStorePresence(envAppId, deps.dataDir) === 'unmigrated') {
+ return {
+ ok: false,
+ reason: 'unmigrated',
+ message: formatUnmigratedMessage({ sessionScoped: isSessionScopedCliProcess(env) }),
+ };
+ }
+ const snapshot: SessionsSnapshot = (deps.loadSnapshot ?? loadAllSessionsSnapshot)({
+ dataDir: deps.dataDir,
+ fallbackAppId: envAppId,
+ knownAppIds: deps.knownAppIds ?? knownBotAppIds({ dataDir: deps.dataDir, env }),
+ });
+ const hit = snapshot.get(sessionId);
+ if (hit) {
+ const bad = checkReturnedRow(hit, sessionId, envAppId);
+ if (bad) return bad;
+ return { ok: true, session: hit, source: 'store' };
+ }
+ const unmigrated = snapshot.unmigratedAppIds ?? [];
+ if (unmigrated.length > 0 && !hit) {
+ // No row in ready stores, and at least one bot is still on JSON — the
+ // session may live there. Fail with unmigrated rather than a silent miss.
+ if (!envAppId || unmigrated.includes(envAppId) || unmigrated.includes('')) {
+ return {
+ ok: false,
+ reason: 'unmigrated',
+ message: formatUnmigratedMessage({ sessionScoped: isSessionScopedCliProcess(env) }),
+ };
+ }
+ }
+ return { ok: false, reason: 'not_found', message: `未找到 session ${sessionId}` };
+}
+
+export async function resolveSessionById(
+ sessionId: string,
+ deps: ResolveSessionByIdDeps,
+): Promise {
+ const env = deps.env ?? process.env;
+ const findDaemon = deps.findDaemon ?? findOnlineDaemon;
+ const listDaemons = deps.listDaemons ?? listOnlineDaemons;
+ const envAppId = env.BOTMUX_LARK_APP_ID;
+
+ const candidates: OnlineDaemonInfo[] = [];
+ if (envAppId) {
+ try {
+ const one = findDaemon(envAppId, deps.dataDir);
+ if (one) candidates.push(one);
+ } catch { /* unreadable registry → store fallback */ }
+ } else {
+ try { candidates.push(...listDaemons(deps.dataDir)); } catch { /* store fallback */ }
+ }
+
+ let sawAuthoritativeMiss = false;
+ for (const daemon of candidates) {
+ const asked = await askDaemon(daemon, sessionId, { ...deps, env });
+ if (asked.status === 'ok') return { ok: true, session: asked.session, source: 'daemon' };
+ if (asked.status === 'rejected') return asked.err;
+ if (asked.status === 'not_found') {
+ // Only the owning daemon's 404 is authoritative. A 404 from a daemon
+ // reached by enumeration covers just that bot's store — keep asking,
+ // then read the store (an offline bot's rows live only on disk).
+ if (envAppId) { sawAuthoritativeMiss = true; break; }
+ }
+ }
+ if (sawAuthoritativeMiss) {
+ return { ok: false, reason: 'not_found', message: `未找到 session ${sessionId}` };
+ }
+ return readFromStore(sessionId, { ...deps, env });
+}
diff --git a/src/core/current-turn-provenance.ts b/src/core/current-turn-provenance.ts
index 3b0c9b195e..19cab25735 100644
--- a/src/core/current-turn-provenance.ts
+++ b/src/core/current-turn-provenance.ts
@@ -55,7 +55,7 @@ function readPersistedSession(dataDir: string, sessionId: string): PersistedTurn
// a duplicated row must not be used to infer the caller — stays here.
let matches: readonly unknown[];
try {
- matches = readSessionRowCopiesAcrossStores(sessionId, dataDir);
+ matches = readSessionRowCopiesAcrossStores(sessionId, dataDir).matches;
} catch (err) {
throw new CurrentTurnProvenanceError(
`无法读取 botmux session store:${err instanceof Error ? err.message : String(err)}`,
diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts
index a302968bdd..2af838dcc9 100644
--- a/src/core/dashboard-ipc-server.ts
+++ b/src/core/dashboard-ipc-server.ts
@@ -2427,7 +2427,7 @@ function buildAsyncTriggerLookupResponse(sessionId: string, triggerId?: string):
const persistedRaw = asyncTriggerStore.lookup(sessionId, triggerId);
// Cross-bot isolation (fail-closed / positive-proof) — see decideAsyncOwnership.
- // Both sessionStore.getSession() (cross-scans every bot's sessions-*.json) and
+ // Both sessionStore.getSession() (cross-scans every bot's SQLite store) and
// the async store (machine-wide shared dir) can surface another bot's data for
// a sessionId routed to THIS daemon; keep only sources positively proven ours.
const decision = decideAsyncOwnership({
diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts
index a70aac417e..5cab758967 100644
--- a/src/core/dashboard-rows.ts
+++ b/src/core/dashboard-rows.ts
@@ -6,7 +6,7 @@
// module so worker-pool can import the composer without pulling in the IPC
// server (which itself imports worker-pool — that would be a cycle).
import type { DaemonSession } from './types.js';
-import type { Session, StreamStatus } from '../types.js';
+import type { CodexAppDispatchLedgerEntry, ReplyTargetEntry, Session, StreamStatus } from '../types.js';
import type { CliId } from '../adapters/cli/types.js';
import { basename } from 'node:path';
import { getTerminalAdvertisedPort } from './terminal-url.js';
@@ -128,6 +128,29 @@ export interface SessionRow extends SessionMessagePreview {
repoName?: string;
/** Current branch of workingDir; absent for detached HEAD / non-repo. */
gitBranch?: string;
+ /** Per-turn reply anchors — `botmux send` prefers these over the topic root. */
+ replyTargets?: Record;
+ currentReplyTarget?: Session['currentReplyTarget'];
+ quoteTargetId?: string;
+ quoteTargetSenderOpenId?: string;
+ codexAppDispatchLedger?: CodexAppDispatchLedgerEntry[];
+}
+
+function composeSendRoutingFields(
+ s: Session,
+ runtimeCurrentReplyTarget?: Session['currentReplyTarget'],
+): Pick<
+ SessionRow,
+ 'replyTargets' | 'currentReplyTarget' | 'quoteTargetId' | 'quoteTargetSenderOpenId' | 'codexAppDispatchLedger'
+> {
+ const currentReplyTarget = runtimeCurrentReplyTarget ?? s.currentReplyTarget;
+ return {
+ ...(s.replyTargets ? { replyTargets: s.replyTargets } : {}),
+ ...(currentReplyTarget ? { currentReplyTarget } : {}),
+ ...(s.quoteTargetId ? { quoteTargetId: s.quoteTargetId } : {}),
+ ...(s.quoteTargetSenderOpenId ? { quoteTargetSenderOpenId: s.quoteTargetSenderOpenId } : {}),
+ ...(s.codexAppDispatchLedger ? { codexAppDispatchLedger: s.codexAppDispatchLedger } : {}),
+ };
}
export function feishuChatLink(chatId: string, brand: Brand = 'feishu'): string {
@@ -314,6 +337,7 @@ export function composeRowFromActive(ds: DaemonSession, opts?: DashboardRowOptio
...(ds.worker?.pid !== undefined ? { workerPid: ds.worker.pid } : {}),
...(ds.adoptedFrom?.originalCliPid !== undefined ? { adoptCliPid: ds.adoptedFrom.originalCliPid } : {}),
...buildSessionMessagePreview(ds.session),
+ ...composeSendRoutingFields(ds.session, ds.currentReplyTarget),
};
}
@@ -357,6 +381,7 @@ export function composeRowFromClosed(s: Session, opts?: DashboardRowOptions): Se
...(topicLink ? { feishuThreadLink: topicLink } : {}),
tokenUsage: maybeSessionTokenUsage(s, undefined, opts, { usePersistedSnapshot: true }),
...buildSessionMessagePreview(s),
+ ...composeSendRoutingFields(s),
};
}
@@ -409,5 +434,6 @@ export function composeRowFromPersistedActive(s: Session, opts?: DashboardRowOpt
quarantined: !!s.restoreQuarantinedAt,
tokenUsage: maybeSessionTokenUsage(s, undefined, opts),
...buildSessionMessagePreview(s),
+ ...composeSendRoutingFields(s),
};
}
diff --git a/src/core/fleet-supervisor.ts b/src/core/fleet-supervisor.ts
index 39f00b2031..ab8a74fd66 100644
--- a/src/core/fleet-supervisor.ts
+++ b/src/core/fleet-supervisor.ts
@@ -28,6 +28,7 @@ import {
} from './fleet-supervisor-policy.js';
import { mutateFleetState, readFleetState } from './fleet-state-store.js';
import type { FleetCommand } from './fleet-command-queue.js';
+import { FLEET_DAEMON_KILL_TIMEOUT_MS } from './shutdown-budgets.js';
export interface FleetBotSpec {
/** botmux- process name (or 'botmux-dashboard' for the dashboard). */
@@ -135,7 +136,7 @@ export class FleetSupervisor {
constructor(private readonly opts: FleetSupervisorOptions) {
this.policy = opts.policy ?? DEFAULT_RESTART_POLICY;
- this.killTimeoutMs = opts.killTimeoutMs ?? 8000;
+ this.killTimeoutMs = opts.killTimeoutMs ?? FLEET_DAEMON_KILL_TIMEOUT_MS;
this.log = opts.log ?? ((m) => console.error(`[fleet-supervisor] ${m}`));
}
diff --git a/src/core/mojo-containment-command.ts b/src/core/mojo-containment-command.ts
index 5a8c0e1e0b..b7b06ef97c 100644
--- a/src/core/mojo-containment-command.ts
+++ b/src/core/mojo-containment-command.ts
@@ -65,30 +65,24 @@ const USAGE = `用法:
* unavailable"), which does NOT block the revoke but IS surfaced to the
* operator; only a definite `status === 'active'` blocks.
*/
-export async function defaultIsSessionActive(sessionId: string): Promise {
+export async function defaultIsSessionActive(
+ sessionId: string,
+ dataDir?: string,
+): Promise {
try {
- const [{ config }, { readdirSync, readFileSync }, { join }] = await Promise.all([
- import('../config.js'),
- import('node:fs'),
- import('node:path'),
- ]);
- const dir = config.session.dataDir;
- let sawUnreadable = false;
- for (const file of readdirSync(dir)) {
- if (!/^sessions(-[^.]+)?\.json$/.test(file)) continue;
- try {
- const data = JSON.parse(readFileSync(join(dir, file), 'utf-8')) as
- Record;
- const row = data[sessionId];
- if (row) return row.status === 'active';
- } catch {
- // A corrupt file may be the very one hiding this row: unknown, never
- // "proven inactive".
- sawUnreadable = true;
- }
- }
- return sawUnreadable ? undefined : false;
- } catch {
+ const { config } = await import('../config.js');
+ const {
+ readSessionRowCopiesAcrossStores,
+ SessionStoreSqliteUnavailableError,
+ } = await import('../services/session-store.js');
+ const dir = dataDir ?? config.session.dataDir;
+ const { matches, unreadableStores } = readSessionRowCopiesAcrossStores(sessionId, dir);
+ if (matches.some(s => s.status === 'active')) return true;
+ if (unreadableStores > 0) return undefined;
+ return false;
+ } catch (err) {
+ const { SessionStoreSqliteUnavailableError } = await import('../services/session-store.js');
+ if (err instanceof SessionStoreSqliteUnavailableError) return undefined;
return undefined;
}
}
@@ -214,7 +208,8 @@ export async function runMojoContainmentCommand(
}
}
}
- const active = await (deps.isSessionActive ?? defaultIsSessionActive)(sessionId);
+ const active = await (deps.isSessionActive
+ ?? ((id: string) => defaultIsSessionActive(id, deps.dataDir)))(sessionId);
if (active === true) {
liveBlockers.push(`session ${sessionId} 的会话行仍处于 active(先 /close 它)`);
} else if (active === undefined) {
diff --git a/src/core/restart-report.ts b/src/core/restart-report.ts
index 5d516cc95c..d4e027d1d4 100644
--- a/src/core/restart-report.ts
+++ b/src/core/restart-report.ts
@@ -9,7 +9,7 @@
import { githubAuthHeaders, type GithubAuthResolveOptions } from './github-auth.js';
import type { RestartKind } from '../services/restart-intent-store.js';
import { claimRestartIntentForReport } from '../services/restart-intent-store.js';
-import { countActiveSessionsOnDisk } from '../services/session-store.js';
+import { countActiveSessionsOnDisk, SessionStoreSqliteUnavailableError } from '../services/session-store.js';
import { botmuxVersion } from '../utils/install-info.js';
import { t, localeForBot, type Locale } from '../i18n/index.js';
@@ -129,7 +129,13 @@ export async function sendRestartReportIfPending(w: RestartReportWiring): Promis
if (!w.ownerOpenId) { log('restart-report: no owner configured — skipping DM'); return; }
const locale = localeForBot(w.primaryLarkAppId);
- const sessionCount = countActiveSessionsOnDisk();
+ let sessionCount = 0;
+ try {
+ sessionCount = countActiveSessionsOnDisk();
+ } catch (err) {
+ if (!(err instanceof SessionStoreSqliteUnavailableError)) throw err;
+ log(`restart-report: session store unreadable — ${err.message}`);
+ }
const version = botmuxVersion();
let changelog: string | undefined;
if (intent.kind === 'update' && intent.newVersion) {
diff --git a/src/core/shutdown-budgets.ts b/src/core/shutdown-budgets.ts
index 64dced11c7..10e0b08549 100644
--- a/src/core/shutdown-budgets.ts
+++ b/src/core/shutdown-budgets.ts
@@ -43,25 +43,26 @@ export const DAEMON_SHUTDOWN_MAX_MS =
+ REMOTE_SHUTDOWN_BATCH_PERSIST_TIMEOUT_MS
+ Math.max(REMOTE_ADMISSION_RESTORE_TIMEOUT_MS, DAEMON_WORKER_EXIT_GRACE_MS)
+ DAEMON_SHUTDOWN_OVERHEAD_MS;
-export const PM2_DAEMON_KILL_TIMEOUT_MS = 29_000;
+/** Supervisor SIGTERM→SIGKILL budget. Must exceed DAEMON_SHUTDOWN_MAX_MS. */
+export const FLEET_DAEMON_KILL_TIMEOUT_MS = 29_000;
export const PM2_DAEMON_RESTART_DELAY_MS = 3_000;
/** A full restart-delay plus projection jitter. The fleet helper must observe
* this quiet window after every signalled generation exits. */
export const FLEET_SUCCESSOR_SETTLE_MS = PM2_DAEMON_RESTART_DELAY_MS + 500;
export const FLEET_DAEMON_EXIT_WAIT_MS = 60_000;
-if (PM2_DAEMON_KILL_TIMEOUT_MS <= DAEMON_SHUTDOWN_MAX_MS) {
- throw new Error('PM2 daemon kill timeout must exceed the complete daemon shutdown budget');
+if (FLEET_DAEMON_KILL_TIMEOUT_MS <= DAEMON_SHUTDOWN_MAX_MS) {
+ throw new Error('fleet supervisor killTimeoutMs must exceed the complete daemon shutdown budget');
}
if (DAEMON_SHUTDOWN_MAX_MS > 28_000) {
throw new Error('complete daemon shutdown budget must remain at or below 28 seconds');
}
-if (FLEET_DAEMON_EXIT_WAIT_MS <= PM2_DAEMON_KILL_TIMEOUT_MS) {
- throw new Error('fleet restart wait must exceed the PM2 daemon kill timeout');
+if (FLEET_DAEMON_EXIT_WAIT_MS <= FLEET_DAEMON_KILL_TIMEOUT_MS) {
+ throw new Error('fleet restart wait must exceed the fleet supervisor killTimeoutMs');
}
if (FLEET_DAEMON_EXIT_WAIT_MS <= DAEMON_SHUTDOWN_MAX_MS + FLEET_SUCCESSOR_SETTLE_MS) {
throw new Error('fleet restart wait must cover daemon shutdown plus successor quiet window');
}
-if (FLEET_DAEMON_EXIT_WAIT_MS <= PM2_DAEMON_KILL_TIMEOUT_MS + FLEET_SUCCESSOR_SETTLE_MS) {
- throw new Error('fleet restart wait must cover PM2 kill timeout plus successor quiet window');
+if (FLEET_DAEMON_EXIT_WAIT_MS <= FLEET_DAEMON_KILL_TIMEOUT_MS + FLEET_SUCCESSOR_SETTLE_MS) {
+ throw new Error('fleet restart wait must cover fleet supervisor killTimeoutMs plus successor quiet window');
}
diff --git a/src/core/supervisor-shutdown-protocol.ts b/src/core/supervisor-shutdown-protocol.ts
index 40950db300..b6f3681fad 100644
--- a/src/core/supervisor-shutdown-protocol.ts
+++ b/src/core/supervisor-shutdown-protocol.ts
@@ -1,19 +1,10 @@
import { PM2_GRACEFUL_EXIT_CODE } from '../pm2-graceful-exit.js';
-/**
- * Descriptor capability required before a supervisor may signal a live daemon.
- * Bump this exact value whenever shutdown safety depends on a protocol that an
- * already-running older daemon does not implement.
- */
-export const SUPERVISOR_SHUTDOWN_PROTOCOL = 'riff-fleet-prepare-persist-commit-managed-sentinel-v3' as const;
-
/**
* PM2 normalizes signal-only child exits to code 0 (`code || 0`) before it
* evaluates `stop_exit_codes`. Zero therefore cannot prove that the daemon
- * completed the protocol above: SIGKILL/OOM may look identical. Only the
- * successful end of a PM2-managed daemon.shutdown() exits with this reserved
+ * completed its shutdown protocol: SIGKILL/OOM may look identical. Only the
+ * successful end of a supervised daemon.shutdown() exits with this reserved
* non-zero code; foreground launches keep the conventional zero exit.
*/
export const DAEMON_GRACEFUL_EXIT_CODE = PM2_GRACEFUL_EXIT_CODE;
-
-export type SupervisorShutdownProtocol = typeof SUPERVISOR_SHUTDOWN_PROTOCOL;
diff --git a/src/daemon.ts b/src/daemon.ts
index 3ea4b81eed..e9c0b7bb24 100644
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -50,10 +50,8 @@ import {
stopCliRuntimeUpdateMonitor,
} from './core/cli-runtime-update.js';
import { sendRestartReportIfPending } from './core/restart-report.js';
-import {
- SUPERVISOR_SHUTDOWN_PROTOCOL,
- type SupervisorShutdownProtocol,
-} from './core/supervisor-shutdown-protocol.js';
+import { botmuxVersion } from './utils/install-info.js';
+import { SESSION_STORE_PROTOCOL } from './utils/daemon-version-display.js';
import { readSupervisorProcessStartIdentity } from './core/process-start-identity.js';
import { statSync } from 'node:fs';
import { addReaction, deleteMessage, getChatContext, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, patchCardStreamElement, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateCardStreamElementContent, updateMessage, type EntryResolveStatus } from './im/lark/client.js';
@@ -4218,8 +4216,9 @@ function removePidFile(): void {
// ─── Daemon descriptor (dashboard registry) ─────────────────────────────────
// Each per-bot daemon publishes a self-descriptor JSON at
// /dashboard-daemons/.json so the dashboard sibling
-// process can discover all running daemons. The file is touched every 30s as a
-// heartbeat (mtime drives offline detection) and removed on graceful exit.
+// process can discover all running daemons. Freshness is the in-file
+// `lastHeartbeat` field (not mtime); the file is rewritten every 30s and
+// removed on graceful exit.
const DAEMON_REGISTRY_DIR = join(resolveBotmuxDataDir(), 'dashboard-daemons');
@@ -4240,9 +4239,10 @@ interface DaemonDescriptor {
bootInstanceId: string;
/** Full-envelope Workflow mutation protocol supported by this process. */
workflowIpcProtocol: 'v1';
- /** Exact supervisor protocol this in-memory daemon will execute on signal.
- * Absent until the SIGTERM/SIGINT handlers and all captured state are ready. */
- supervisorShutdownProtocol?: SupervisorShutdownProtocol;
+ /** Presence-based session-store capability. Copy only; never a write permit. */
+ sessionStoreProtocol: 'occupancy-v1';
+ /** Running binary version. Copy only; never compared by size. */
+ botmuxVersion: string;
lastHeartbeat: number;
/**
* Resolved open_ids from this bot's allowedUsers config (post-email
@@ -22502,6 +22502,8 @@ export async function startDaemon(botIndex?: number): Promise {
startedAt: Date.now(),
bootInstanceId: getDaemonBootId(),
workflowIpcProtocol: 'v1',
+ sessionStoreProtocol: SESSION_STORE_PROTOCOL,
+ botmuxVersion: botmuxVersion(),
lastHeartbeat: Date.now(),
// Dashboard create-group only consumes app-scoped open_ids — publish ONLY
// ou_ entries. Before the resolution below runs, the list may still hold raw
@@ -24162,12 +24164,12 @@ export async function startDaemon(botIndex?: number): Promise {
process.on('SIGTERM', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
process.on('SIGINT', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
- // Capability publication is the final startup commit for supervisor-driven
- // shutdown. The early descriptor intentionally lacks it: a new CLI that
- // observes this daemon before both handlers/state closures exist must refuse
- // to signal. Atomic rewrite makes the capability visible only afterward.
+ // SIGTERM/SIGINT handlers and the supervisor-shutdown IPC handler must be
+ // installed before this process is considered ready to take a signal. The
+ // descriptor rewrite after that is a heartbeat bump, not a capability advert
+ // (sessionStoreProtocol is written from the first publish).
if (readSupervisorProcessStartIdentity(process.pid) !== desc.processStartIdentity) {
- throw new Error('daemon process-start identity changed before shutdown capability commit');
+ throw new Error('daemon process-start identity changed before shutdown handler commit');
}
setSupervisorShutdownHandler({
larkAppId: cfg.larkAppId,
@@ -24175,7 +24177,6 @@ export async function startDaemon(botIndex?: number): Promise {
processStartIdentity: desc.processStartIdentity,
shutdown,
});
- desc.supervisorShutdownProtocol = SUPERVISOR_SHUTDOWN_PROTOCOL;
desc.lastHeartbeat = Date.now();
writeDaemonDescriptor(desc);
// Best-effort cleanup on plain `exit` (e.g. uncaught fatal). No worker
diff --git a/src/dashboard.ts b/src/dashboard.ts
index 5245a6e10a..c8d7e80ab7 100644
--- a/src/dashboard.ts
+++ b/src/dashboard.ts
@@ -179,6 +179,7 @@ import { dashboardSecretPath } from './core/dashboard-secret.js';
import { getGitRepoInfo } from './core/session-row-enrichment.js';
import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, diskVersionAt, botmuxCliEntry, botmuxCliEntryAt, botmuxInstallRoot, bakedBinaryVersion } from './utils/install-info.js';
+import { formatRunningDaemonsRestartSummary } from './utils/daemon-version-display.js';
import { checkNode, detectBotmuxInstalls, resolveCurrentVersion, resolveCurrentVersionAt } from './utils/install-diagnostics.js';
import {
fetchLatestVersion,
@@ -4504,11 +4505,21 @@ const server = createServer(async (req, res) => {
lastCheckedAt: entry.lastCheckedAt,
}));
const localDev = isLocalDevInstall();
+ const runningDaemons = registry.list().map(d => ({
+ larkAppId: d.larkAppId,
+ version: d.botmuxVersion,
+ }));
+ const runningDaemonRestartHint = formatRunningDaemonsRestartSummary(
+ runningDaemons.map(d => d.version),
+ current,
+ );
return jsonRes(res, 200, {
current,
latest,
versionLookupOk: latestResult.lookupOk,
behind: !!latest && isNewerVersion(latest, current),
+ runningDaemons,
+ ...(runningDaemonRestartHint ? { runningDaemonRestartHint } : {}),
cliBehind: cliUpdates.some((entry) => entry.updateAvailable),
cliUpdates,
localDevInstall: localDev,
diff --git a/src/dashboard/registry.ts b/src/dashboard/registry.ts
index 48e83b1ef1..1c3639c366 100644
--- a/src/dashboard/registry.ts
+++ b/src/dashboard/registry.ts
@@ -17,6 +17,10 @@ export interface DaemonInfo {
bootInstanceId?: string;
/** Auth protocol advertised atomically with the boot identity. */
workflowIpcProtocol?: string;
+ /** Presence-based session-store capability. Copy only; never a write permit. */
+ sessionStoreProtocol?: string;
+ /** Running binary version. Copy only; never compared by size. */
+ botmuxVersion?: string;
lastHeartbeat: number;
/**
* open_ids of users the bot's allowedUsers list was resolved to (post-email
@@ -112,6 +116,12 @@ export class DaemonRegistry {
if (!n.endsWith('.json')) continue;
try {
const d = JSON.parse(readFileSync(join(this.dir, n), 'utf8')) as DaemonInfo;
+ if (typeof d.sessionStoreProtocol !== 'string' || !d.sessionStoreProtocol) {
+ delete d.sessionStoreProtocol;
+ }
+ if (typeof d.botmuxVersion !== 'string' || !d.botmuxVersion) {
+ delete d.botmuxVersion;
+ }
next.set(d.larkAppId, d);
} catch {
// Skip malformed / partially-written files
diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx
index f1bf259e37..53f5cae75f 100644
--- a/src/dashboard/web/app.tsx
+++ b/src/dashboard/web/app.tsx
@@ -67,6 +67,8 @@ type BotmuxUpdateStatus = {
updateCommand: string | null;
node: { version: string; required: number; ok: boolean };
installs: { entries: Array<{ binPath: string }>; multiple: boolean };
+ runningDaemons?: Array<{ larkAppId: string; version?: string }>;
+ runningDaemonRestartHint?: string;
};
type NavItem = {
@@ -829,16 +831,6 @@ function TopbarVersionControl(props: {
try {
const previousInstance = await dashboardInstance();
const result = await updateAndRestartBotmux(fetch, setPhase);
- if (result.bootstrapRequired) {
- // The new binary is installed, but a normal restart is refused because
- // live daemons still run the pre-signal-death-autorestart PM2 policy.
- // Point the operator at the one-time terminal bootstrap instead of
- // polling a reconnect that can never happen.
- actionInFlightRef.current = false;
- setPhase('error');
- setErrorDetail(t('update.bootstrapRequired'));
- return;
- }
if (!result.restarted) {
// Update installed but the restart handoff failed — surface it
// directly instead of polling for a reconnect that will never come.
@@ -1042,6 +1034,9 @@ function TopbarVersionControl(props: {
role={phase === 'error' || refreshFailed ? 'alert' : 'status'}
aria-live="polite"
>{message}
+ {status.runningDaemonRestartHint ? (
+ {status.runningDaemonRestartHint}
+ ) : null}
= {
'sessions.history.bot': 'Bot',
'sessions.history.system': 'System',
'sessions.history.owner': 'Owner',
- 'sessions.history.staleHint': 'The dashboard or daemon process may still run an old build — botmux restart and retry',
+ 'sessions.history.staleHint': 'Running daemon v{running} does not match disk v{disk}; run botmux restart to apply',
'groups.title': 'Group Management',
'groups.matrixTitle': 'Group Matrix',
'groups.search': 'Search chat name / ID / owner',
@@ -4741,7 +4740,6 @@ const en: Record = {
'update.btnLater': 'Later',
'update.restartSlow': 'Restart is taking a while; please refresh the page manually.',
'update.restartFailed': 'Restart failed: {detail}',
- 'update.bootstrapRequired': 'The new version is installed, but the currently running daemons predate the signal-death autorestart protocol, so the dashboard cannot safely restart them. At the machine, confirm every session is idle and run in a terminal: botmux restart --bootstrap-shutdown-protocol --yes to complete this one-time upgrade.',
'skills.title': 'Skill Management',
'skills.installed': 'Installed Skills',
'skills.installedHelp': 'Skills installed locally and available to Bots.',
diff --git a/src/dashboard/web/sessions-page.tsx b/src/dashboard/web/sessions-page.tsx
index 8b5bfdc1e1..b310d14b66 100644
--- a/src/dashboard/web/sessions-page.tsx
+++ b/src/dashboard/web/sessions-page.tsx
@@ -19,6 +19,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { closeResidualIsLocal, describeCloseResidual, parseCloseResidual } from '../../core/close-residual.js';
+import { daemonVersionDiffersFromDisk, stripBotmuxVersionPrefix } from '../../utils/daemon-version-display.js';
import {
IDLE_CLEANUP_HOUR_OPTIONS,
parseIdleCleanupHours,
@@ -151,7 +152,7 @@ type HistoryState = {
messages: any[];
ownerOpenId?: string;
error?: string;
- stale?: boolean;
+ staleHint?: { running: string; disk: string };
};
type TerminalState = {
@@ -1914,7 +1915,9 @@ function HistoryModal(props: { state: HistoryState | null; onClose: () => void }
{!props.state.loading && props.state.error ? (
{t('sessions.history.fail')}: {props.state.error}
- {props.state.stale ? <>
{t('sessions.history.staleHint')}> : null}
+ {props.state.staleHint ? (
+ <>
{t('sessions.history.staleHint', props.state.staleHint)}>
+ ) : null}
) : null}
{!props.state.loading && !props.state.error && props.state.messages.length === 0 ? (
@@ -3476,8 +3479,23 @@ function SessionsPage(): React.JSX.Element {
const body = await r.json().catch(() => ({}));
if (!r.ok || body?.ok === false) {
const errCode = String(body?.error ?? r.status);
- const stale = errCode === 'not_found_yet' || errCode === 'not_found';
- setHistoryState(prev => prev?.sessionId === row.sessionId ? { sessionId: row.sessionId, loading: false, messages: [], error: errCode, stale } : prev);
+ let staleHint: HistoryState['staleHint'];
+ if ((errCode === 'not_found_yet' || errCode === 'not_found') && row.larkAppId) {
+ try {
+ const status = await fetch('/api/update/status', { cache: 'no-store' }).then(res => res.json()) as {
+ current?: string;
+ runningDaemons?: Array<{ larkAppId: string; version?: string }>;
+ };
+ const running = status.runningDaemons?.find(d => d.larkAppId === row.larkAppId)?.version;
+ if (daemonVersionDiffersFromDisk(running, status.current)) {
+ staleHint = {
+ running: stripBotmuxVersionPrefix(running!),
+ disk: stripBotmuxVersionPrefix(status.current!),
+ };
+ }
+ } catch { /* raw not_found only */ }
+ }
+ setHistoryState(prev => prev?.sessionId === row.sessionId ? { sessionId: row.sessionId, loading: false, messages: [], error: errCode, staleHint } : prev);
return;
}
const messages = Array.isArray(body.messages) ? body.messages : [];
diff --git a/src/dashboard/web/settings-page.tsx b/src/dashboard/web/settings-page.tsx
index 64eb3e3f72..204824353f 100644
--- a/src/dashboard/web/settings-page.tsx
+++ b/src/dashboard/web/settings-page.tsx
@@ -124,6 +124,8 @@ interface UpdateStatus {
updateCommand: string | null;
node: NodeCheck;
installs: { entries: InstallEntry[]; multiple: boolean };
+ runningDaemons?: Array<{ larkAppId: string; version?: string }>;
+ runningDaemonRestartHint?: string;
}
interface ReleaseNote { version: string; name: string; body: string; url: string; publishedAt: string | null }
@@ -1828,6 +1830,7 @@ function UpdateCard(props: {
{tr('update.current')}: v{s.current}{' '}
+ {s.runningDaemonRestartHint ? {s.runningDaemonRestartHint}
: null}
{!s.node.ok ? {tr('update.nodeWarn', { version: s.node.version, required: s.node.required })}
: null}
{!s.localDevInstall && !s.updateSupported ? {tr('update.unsupportedInstall')}
: null}
{s.localDevInstall ? {s.localDevUpdatable ? tr('update.localDevUpdatable') : tr('update.localDev')}
: null}
diff --git a/src/dashboard/web/update-action.ts b/src/dashboard/web/update-action.ts
index 6bd9f1ba67..f90cf914e4 100644
--- a/src/dashboard/web/update-action.ts
+++ b/src/dashboard/web/update-action.ts
@@ -9,14 +9,6 @@ export interface BotmuxUpdateResult {
restartError?: string;
/** True when a restart was already in progress (another driver claimed the lease). */
alreadyScheduled?: boolean;
- /** True when the new binary is installed but a normal restart is refused
- * because live daemons still run the pre-signal-death-autorestart PM2 policy.
- * The operator must run the one-time `--bootstrap-shutdown-protocol` upgrade
- * from a terminal. Distinct from restartError so the UI shows a precise,
- * actionable message instead of a generic failure. */
- bootstrapRequired?: boolean;
- /** Canonical PM2 names still on the old policy (best-effort, may be empty). */
- unsafeDaemons?: string[];
}
type FetchLike = (input: string, init?: RequestInit) => Promise;
@@ -93,16 +85,6 @@ export async function updateAndRestartBotmux(
}),
});
const restart = await responseBody(restartResponse);
- if (restart.error === 'bootstrap_shutdown_protocol_required') {
- // Installed successfully, but the fleet predates the signal-death
- // autorestart protocol: a normal restart fails closed. Surface this as its
- // own state so the UI can point the operator at the one-time bootstrap
- // command rather than polling a reconnect that will never happen.
- const unsafe = Array.isArray(restart.unsafeDaemons)
- ? restart.unsafeDaemons.filter((name): name is string => typeof name === 'string')
- : [];
- return { ...result, restarted: false, bootstrapRequired: true, unsafeDaemons: unsafe };
- }
if (!restartResponse.ok || restart.ok === false) {
// The update itself succeeded — the new version is already installed.
// Return restarted:false instead of throwing so the caller can surface a
diff --git a/src/index-supervisor.ts b/src/index-supervisor.ts
index 0782172eb5..76c3661474 100644
--- a/src/index-supervisor.ts
+++ b/src/index-supervisor.ts
@@ -58,6 +58,7 @@ async function main(): Promise {
const { fleetStatePath, fleetDistDir, fleetLogDir, fleetCommandPath, resolveFleetBots, resolveFleetMembers, resolveFleetDaemonEnv, fleetDaemonNodeArgs } = await import('./core/fleet-runtime.js');
const { drainFleetCommands } = await import('./core/fleet-command-queue.js');
const { logger } = await import('./utils/logger.js');
+ const { FLEET_DAEMON_KILL_TIMEOUT_MS } = await import('./core/shutdown-budgets.js');
// Every supervised member: the bot daemons from bots.json PLUS the dashboard.
// The dashboard is always present (mirrors the old pm2 ecosystem, which always
@@ -74,6 +75,7 @@ async function main(): Promise {
cwd: configDir,
daemonNodeArgs: fleetDaemonNodeArgs(),
logDir: fleetLogDir(),
+ killTimeoutMs: FLEET_DAEMON_KILL_TIMEOUT_MS,
log: (m) => logger.info(`[supervisor] ${m}`),
});
diff --git a/src/services/known-bot-app-ids.ts b/src/services/known-bot-app-ids.ts
new file mode 100644
index 0000000000..7e3564b0ce
--- /dev/null
+++ b/src/services/known-bot-app-ids.ts
@@ -0,0 +1,60 @@
+/**
+ * Which app ids count as "a bot on this machine" for the unmigrated probe.
+ *
+ * A leftover `sessions-.json` without a `.db` means "that bot's daemon
+ * has not migrated yet" only while the bot still exists: configured in
+ * bots.json, currently advertising a descriptor, or the bot this process runs
+ * under. Files left behind by bots removed from bots.json are abandoned data,
+ * not a pending upgrade — no daemon will ever import them — so they must not
+ * trigger the "restart the daemon" hint.
+ *
+ * Every source is best-effort: a sandboxed CLI cannot read bots.json (denied
+ * → treated as absent) and may not list descriptors; its own app id still
+ * comes from the environment.
+ */
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { listOnlineDaemons } from '../utils/daemon-discovery.js';
+
+export function defaultBotsJsonPath(env: NodeJS.ProcessEnv = process.env): string {
+ return env.BOTS_CONFIG || join(homedir(), '.botmux', 'bots.json');
+}
+
+function configuredBotAppIds(botsJsonPath: string): string[] {
+ let raw: string;
+ try {
+ if (!existsSync(botsJsonPath)) return [];
+ raw = readFileSync(botsJsonPath, 'utf-8');
+ } catch {
+ return [];
+ }
+ let parsed: unknown;
+ try { parsed = JSON.parse(raw); } catch { return []; }
+ const list = Array.isArray(parsed) ? parsed : (parsed as { bots?: unknown } | null)?.bots;
+ if (!Array.isArray(list)) return [];
+ const ids: string[] = [];
+ for (const entry of list) {
+ const bot = (entry ?? {}) as { larkAppId?: unknown; appId?: unknown };
+ const id = typeof bot.larkAppId === 'string' && bot.larkAppId
+ ? bot.larkAppId
+ : typeof bot.appId === 'string' && bot.appId ? bot.appId : undefined;
+ if (id) ids.push(id);
+ }
+ return ids;
+}
+
+export function knownBotAppIds(opts: {
+ dataDir: string;
+ env?: NodeJS.ProcessEnv;
+ botsJsonPath?: string;
+}): Set {
+ const env = opts.env ?? process.env;
+ const known = new Set();
+ if (env.BOTMUX_LARK_APP_ID) known.add(env.BOTMUX_LARK_APP_ID);
+ for (const id of configuredBotAppIds(opts.botsJsonPath ?? defaultBotsJsonPath(env))) known.add(id);
+ try {
+ for (const daemon of listOnlineDaemons(opts.dataDir)) known.add(daemon.larkAppId);
+ } catch { /* unreadable registry → nothing to add */ }
+ return known;
+}
diff --git a/src/services/session-command-host.ts b/src/services/session-command-host.ts
index 21b4f099ba..32e75ea552 100644
--- a/src/services/session-command-host.ts
+++ b/src/services/session-command-host.ts
@@ -11,13 +11,12 @@
*
* `session-store.applySessionCommandUnowned` / `readSessionRowUnowned`
* implement the exclusion and the in-txn occupancy read. This module supplies
- * the heartbeat probe that still decides when no live lease exists (the
- * upgrade window: a daemon that writes SQLite but not occupancy), keeps the
- * probe and the store access on the SAME data dir, and runs the post-commit
- * cleanup a close hands back. A sandboxed / read-isolated CLI must never reach
- * this module's writes (see `isIsolatedCliProcess`): it can only send.
+ * the heartbeat probe that still decides when no live lease exists. The probe
+ * only refuses (with a reason); it never permits a write. A sandboxed /
+ * read-isolated CLI must never reach this module's writes
+ * (see `isIsolatedCliProcess`): it can only send.
*
- * Design: docs/design/2026-08-12-session-restage-store-first.md §1, §3 Stage 2.
+ * Design: docs/design/2026-08-12-session-restage-store-first.md §1, §3.4.
*/
import { config } from '../config.js';
import { cleanupMaterializedDashboardImages } from '../core/dashboard-images.js';
@@ -26,54 +25,64 @@ import { logger } from '../utils/logger.js';
import type { HostSessionCommand } from './session-commands.js';
import {
applySessionCommandUnowned,
- occupancyLeaseIsActive,
+ hostOccupancyLeaseHeld,
readOccupancyLease,
readSessionRowUnowned,
+ SessionStoreSqliteUnavailableError,
type OccupancyLease,
type UnownedRowApply,
type UnownedRowRead,
} from './session-store.js';
+import type { HolderReason } from './session-store-copy.js';
export type { UnownedRowApply, UnownedRowRead } from './session-store.js';
+export type { HolderReason } from './session-store-copy.js';
type HostTarget = { sessionId: string; larkAppId?: string };
-function legacyHeartbeatHeld(larkAppId: string, dataDir: string): boolean {
- try { return !!findOnlineDaemon(larkAppId, dataDir); }
- catch { return false; /* unreadable registry → treat as offline */ }
+/**
+ * Fresh descriptor → reason. Missing file is NOT "old version".
+ * Unreadable registry is treated as offline (no reason).
+ */
+export function probeHolder(larkAppId: string | undefined, dataDir: string): HolderReason | undefined {
+ if (!larkAppId) return undefined;
+ let daemon;
+ try { daemon = findOnlineDaemon(larkAppId, dataDir); }
+ catch { return undefined; }
+ if (!daemon) return undefined;
+ return daemon.sessionStoreProtocol ? 'daemon_without_lease' : 'legacy_daemon';
}
-/** A row with no `larkAppId` is a pre-per-bot legacy row in the flat store:
- * no daemon owns one — daemons all run per-bot stores — so there is nothing
- * to probe. */
-function hostOptions(target: HostTarget, dataDir: string): { dataDir: string; abortIf?: () => boolean } {
- const larkAppId = target.larkAppId;
+function hostOptions(target: HostTarget, dataDir: string): {
+ dataDir: string;
+ probeHolder: () => HolderReason | undefined;
+} {
return {
dataDir,
- ...(larkAppId ? { abortIf: () => legacyHeartbeatHeld(larkAppId, dataDir) } : {}),
+ probeHolder: () => probeHolder(target.larkAppId, dataDir),
};
}
/**
- * Whether this bot's store is held by a live host.
+ * Whether this bot's store is held by a live host, and why.
*
- * A live occupancy lease is the authority. Without one (row absent, expired,
- * or unreadable) the descriptor heartbeat still counts — the upgrade window
- * for daemons that write SQLite but not occupancy, including a rollback that
- * runs behind a stale row a crashed newer build left. Never throws: callers
- * sit inside IPC error handlers, and an unreadable store (sandbox read-only
- * grant, corrupt file, no SQLite engine) must not replace their own error.
+ * A live occupancy lease is the authority. Without one the descriptor
+ * heartbeat still refuses. Never throws.
*/
export function isOccupancyHeld(
larkAppId: string,
options: { dataDir?: string; now?: number } = {},
-): boolean {
+): HolderReason | undefined {
const dataDir = options.dataDir ?? config.session.dataDir;
const now = options.now ?? Date.now();
let lease: OccupancyLease | undefined;
try { lease = readOccupancyLease(larkAppId, dataDir); }
- catch { lease = undefined; /* unreadable store → the heartbeat decides */ }
- return occupancyLeaseIsActive(lease, now) || legacyHeartbeatHeld(larkAppId, dataDir);
+ catch (err) {
+ if (err instanceof SessionStoreSqliteUnavailableError) return 'store_unreadable';
+ lease = undefined;
+ }
+ if (hostOccupancyLeaseHeld(lease, now)) return 'lease';
+ return probeHolder(larkAppId, dataDir);
}
/** Exclusion-ordered fresh read of one exact row while its owning daemon is
@@ -89,12 +98,9 @@ export function readSessionRowAsHost(
/**
* Apply one command to one exact row only while its owning daemon is absent.
*
- * `applied` / `noop` are the command's success (a re-applied command changes
- * nothing and keeps e.g. the original `closedAt`); `refused` is the command's
+ * `applied` / `noop` are the command's success; `refused` is the command's
* own precondition failing on the fresh row; `owned` / `missing` /
- * `contended` mean the store was not this process's to act on. The
- * materialised dashboard images a close releases are deleted here, after the
- * commit, exactly as the daemon does after its own.
+ * `unmigrated` / `contended` mean the store was not this process's to act on.
*/
export function applySessionCommandAsHost(
target: HostTarget,
diff --git a/src/services/session-store-copy.ts b/src/services/session-store-copy.ts
new file mode 100644
index 0000000000..2bab19fbf6
--- /dev/null
+++ b/src/services/session-store-copy.ts
@@ -0,0 +1,59 @@
+/**
+ * Operator vs session-subprocess copy for session-store hold / unmigrated.
+ *
+ * A process with BOTMUX_SESSION_ID or an origin channel only states the
+ * situation — never a fleet-level `botmux restart`, and never pid / port /
+ * version. Those subprocesses can be a legal host inside an unsandboxed
+ * session; an agent that follows a restart instruction would bounce the
+ * whole bot.
+ */
+
+export type HolderReason =
+ | 'lease'
+ | 'daemon_without_lease'
+ | 'legacy_daemon'
+ | 'store_unreadable';
+
+export const UNMIGRATED_OPERATOR_HINT =
+ '会话库尚未迁移到 SQLite,请重启 daemon(`botmux restart`)后重试';
+
+export const LEGACY_DAEMON_OPERATOR_HINT =
+ '后台 daemon 是升级前的旧进程,请先运行 `botmux restart`';
+
+export const DAEMON_ONLINE_OPERATOR_HINT = 'daemon 在线';
+
+export const DAEMON_WITHOUT_LEASE_OPERATOR_HINT =
+ 'daemon 在线但暂未持有会话库租约';
+
+export const STORE_UNREADABLE_OPERATOR_HINT = '会话库不可读';
+
+export const SESSION_STORE_WRITE_BLOCKED =
+ 'daemon 当前不接受会话库写入,本次未做任何修改';
+
+export const SESSION_STORE_UNMIGRATED =
+ '会话库尚未迁移到 SQLite,本次未做任何修改';
+
+export function isSessionScopedCliProcess(env: NodeJS.ProcessEnv = process.env): boolean {
+ return !!(env.BOTMUX_SESSION_ID || env.BOTMUX_ORIGIN_CHANNEL_ID);
+}
+
+export function formatUnmigratedMessage(opts: { sessionScoped?: boolean } = {}): string {
+ return opts.sessionScoped ? SESSION_STORE_UNMIGRATED : UNMIGRATED_OPERATOR_HINT;
+}
+
+export function formatStoreHoldMessage(
+ heldBy: HolderReason,
+ opts: { sessionScoped?: boolean } = {},
+): string {
+ if (opts.sessionScoped) return SESSION_STORE_WRITE_BLOCKED;
+ switch (heldBy) {
+ case 'lease':
+ return DAEMON_ONLINE_OPERATOR_HINT;
+ case 'daemon_without_lease':
+ return DAEMON_WITHOUT_LEASE_OPERATOR_HINT;
+ case 'legacy_daemon':
+ return LEGACY_DAEMON_OPERATOR_HINT;
+ case 'store_unreadable':
+ return STORE_UNREADABLE_OPERATOR_HINT;
+ }
+}
diff --git a/src/services/session-store.ts b/src/services/session-store.ts
index 606d254717..4a68c5d02b 100644
--- a/src/services/session-store.ts
+++ b/src/services/session-store.ts
@@ -1,4 +1,4 @@
-import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync, copyFileSync } from 'node:fs';
+import { readFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync, copyFileSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { config } from '../config.js';
@@ -25,6 +25,8 @@ import type { Session } from '../types.js';
import { configuredCodexInstanceBot, newSessionCodexInstanceState, legacyCodexInstanceBinding, type SessionCreationSource } from './codex-instance-pool.js';
import { botHomePath } from '../adapters/cli/read-isolation.js';
import { resolveCliRuntime, snapshotCliRuntime } from '../adapters/cli/runtime.js';
+import type { HolderReason } from './session-store-copy.js';
+export type { HolderReason } from './session-store-copy.js';
let sessions: Map = new Map();
let loaded = false;
@@ -271,7 +273,9 @@ function readOccupancyInTxn(db: SqliteDatabaseLike): OccupancyLease | undefined
}
}
-/** True when a lease row is present and still inside its TTL. */
+/** True when a lease row is present and still inside its TTL.
+ * Daemon claim/takeover uses this (unexpired AND owner alive). Host writers
+ * use {@link hostOccupancyLeaseHeld} instead. */
export function occupancyLeaseIsActive(
lease: OccupancyLease | undefined,
now: number = Date.now(),
@@ -279,6 +283,17 @@ export function occupancyLeaseIsActive(
return !!lease && lease.leaseUntil > now;
}
+/** Host-side lease: unexpired OR the owner pid is still alive.
+ * Fail-closed — a wedged process whose TTL lapsed still blocks offline writes.
+ * PID reuse can false-positive (host cannot close until the next TTL); a
+ * start-identity column is C-13 and not in this PR. */
+export function hostOccupancyLeaseHeld(
+ lease: OccupancyLease | undefined,
+ now: number = Date.now(),
+): boolean {
+ return !!lease && (lease.leaseUntil > now || processAlive(lease.ownerPid));
+}
+
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -345,31 +360,18 @@ function claimOccupancyOnLoad(db: SqliteDatabaseLike, now: number): void {
}
}
-/**
- * SQLite ownership: a live lease blocks the write outright. Without a live
- * lease (row absent or expired) `abortIf` — the descriptor-heartbeat probe —
- * still decides. That fallback is the upgrade window: a daemon that writes
- * SQLite but not occupancy (a pre-Stage-1 build, or a rollback after a newer
- * build crashed and left a stale row) is visible only by heartbeat. Either
- * signal fails closed; only "no live lease AND no fresh heartbeat" lets an
- * offline writer publish.
- */
-function sqliteOccupancyBlocksWrite(
- lease: OccupancyLease | undefined,
- now: number,
- abortIf?: () => boolean,
-): boolean {
- return occupancyLeaseIsActive(lease, now) || !!abortIf?.();
+function holderFromLease(lease: OccupancyLease | undefined, now: number): HolderReason | undefined {
+ return hostOccupancyLeaseHeld(lease, now) ? 'lease' : undefined;
}
-/** Point-read the bot-scope lease. JSON stores and pre-occupancy DBs → undefined. */
+/** Point-read the bot-scope lease. Missing .db or pre-occupancy DBs → undefined. */
export function readOccupancyLease(
larkAppId: string,
dataDir: string = config.session.dataDir,
): OccupancyLease | undefined {
- const ref = resolveStoreFile(larkAppId, dataDir);
- if (ref.kind !== 'sqlite' || !existsSync(ref.path)) return undefined;
- const db = openDbForRead(ref.path);
+ const dbPath = storeDbPath(larkAppId, dataDir);
+ if (!existsSync(dbPath)) return undefined;
+ const db = openDbForRead(dbPath);
try {
return readOccupancyInTxn(db);
} finally {
@@ -416,15 +418,20 @@ export function __testOnly_setBeforeRowPersist(hook: ((sessionId: string) => voi
testOnlyBeforeRowPersist = hook;
}
-// ─── Store resolution (db-else-json, cross-process only) ─────────────────────
+// ─── Store resolution (SQLite only for cross-process readers) ────────────────
type StoreFileRef = {
/** undefined = the legacy no-appId store. */
appId?: string;
- kind: 'sqlite' | 'json';
path: string;
};
+export type StorePresence = 'ready' | 'unmigrated' | 'absent';
+
+export class SessionStoreUnmigratedError extends Error {
+ override readonly name = 'SessionStoreUnmigratedError';
+}
+
/** Per-bot SQLite stores live in their OWN directory
* (`session-stores//sessions.db`), not as flat sibling files: the CLI
* file sandbox must bind the store as a DIRECTORY. A single-file bwrap bind
@@ -445,36 +452,49 @@ function storeDbPath(appId: string | undefined, dataDir: string): string {
}
/** The pre-SQLite file for a store: the daemon's one-shot import source, and
- * the cross-process read seam until that daemon restarts. */
+ * the unmigrated existence probe (never parsed by cross-process readers). */
function storeJsonFileName(appId: string | undefined): string {
return appId ? `sessions-${appId}.json` : 'sessions.json';
}
-/** Per-store rule for every cross-process reader and CLI offline writer:
- * use the .db when it exists, else the .json (see the upgrade-window note at
- * the top of the engine section). */
-function resolveStoreFile(appId: string | undefined, dataDir: string): StoreFileRef {
+export function classifyStorePresence(appId: string | undefined, dataDir: string): StorePresence {
+ if (existsSync(storeDbPath(appId, dataDir))) return 'ready';
+ if (existsSync(join(dataDir, storeJsonFileName(appId)))) return 'unmigrated';
+ return 'absent';
+}
+
+/** Per-store rule for every cross-process reader: the .db, or nothing. */
+function resolveStoreFile(appId: string | undefined, dataDir: string): StoreFileRef | undefined {
const dbPath = storeDbPath(appId, dataDir);
- if (existsSync(dbPath)) return { appId, kind: 'sqlite', path: dbPath };
- return { appId, kind: 'json', path: join(dataDir, storeJsonFileName(appId)) };
+ if (existsSync(dbPath)) return { appId, path: dbPath };
+ return undefined;
}
-/** One ref per store identity across the whole data dir, .db winning: flat
- * legacy files + per-bot JSON files + per-bot SQLite store directories.
- * `strict` propagates an unlistable `session-stores/` dir (fail-closed
- * callers must not mistake an unreadable store set for an empty one);
- * otherwise it degrades to the JSON view. */
-function listStoreRefs(dataDir: string, opts: { strict?: boolean } = {}): StoreFileRef[] {
- const names = readdirSync(dataDir);
- const dbPaths = new Map();
- const jsonPaths = new Map();
+/** App ids whose `sessions-.json` exists and whose `.db` does not.
+ * Existence only — the JSON is never parsed. */
+export function listUnmigratedAppIds(dataDir: string): string[] {
+ let names: string[] = [];
+ try { names = readdirSync(dataDir); } catch { return []; }
+ const ids: string[] = [];
for (const name of names) {
- if (name === 'sessions.db') dbPaths.set('', join(dataDir, name));
- else if (name === 'sessions.json') jsonPaths.set('', join(dataDir, name));
- else if (name.startsWith('sessions-') && name.endsWith('.json')) {
- jsonPaths.set(name.slice('sessions-'.length, -'.json'.length), join(dataDir, name));
+ if (name === 'sessions.json') {
+ if (!existsSync(storeDbPath(undefined, dataDir))) ids.push('');
+ continue;
}
+ if (!name.startsWith('sessions-') || !name.endsWith('.json')) continue;
+ const appId = name.slice('sessions-'.length, -'.json'.length);
+ if (appId && classifyStorePresence(appId, dataDir) === 'unmigrated') ids.push(appId);
}
+ return ids;
+}
+
+/** One ref per SQLite store identity. `strict` propagates an unlistable
+ * `session-stores/` dir (fail-closed callers must not mistake an unreadable
+ * store set for an empty one). */
+function listStoreRefs(dataDir: string, opts: { strict?: boolean } = {}): StoreFileRef[] {
+ const names = readdirSync(dataDir);
+ const refs: StoreFileRef[] = [];
+ if (names.includes('sessions.db')) refs.push({ path: join(dataDir, 'sessions.db') });
if (names.includes(PER_BOT_STORE_DIRNAME)) {
let appIds: string[] = [];
try {
@@ -484,29 +504,15 @@ function listStoreRefs(dataDir: string, opts: { strict?: boolean } = {}): StoreF
}
for (const appId of appIds) {
const dbPath = storeDbPath(appId, dataDir);
- if (existsSync(dbPath)) dbPaths.set(appId, dbPath);
+ if (existsSync(dbPath)) refs.push({ appId, path: dbPath });
}
}
- const refs: StoreFileRef[] = [];
- for (const key of new Set([...dbPaths.keys(), ...jsonPaths.keys()])) {
- const dbPath = dbPaths.get(key);
- refs.push({
- appId: key === '' ? undefined : key,
- kind: dbPath ? 'sqlite' : 'json',
- path: dbPath ?? jsonPaths.get(key)!,
- });
- }
return refs;
}
/** All [key, value] entries of one store. Throws on an unreadable store;
* callers decide skip-vs-propagate (capability errors always propagate). */
function readStoreEntries(ref: StoreFileRef): [string, Session][] {
- if (ref.kind === 'json') {
- const parsed = JSON.parse(readFileSync(ref.path, 'utf-8')) as unknown;
- if (!parsed || typeof parsed !== 'object') return [];
- return Object.entries(parsed as Record);
- }
const db = openDbForRead(ref.path);
try {
const rows = db.prepare('SELECT session_id, row FROM sessions').all() as { session_id: string; row: string }[];
@@ -522,11 +528,6 @@ function readStoreEntries(ref: StoreFileRef): [string, Session][] {
/** Point-read one key from one store. Throws on an unreadable store. */
function readStoreRowByKey(ref: StoreFileRef, sessionId: string): Session | undefined {
- if (ref.kind === 'json') {
- const parsed = JSON.parse(readFileSync(ref.path, 'utf-8')) as unknown;
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
- return (parsed as Record)[sessionId];
- }
// The daemon's hot freshness reads hit its own store — reuse the attached
// connection instead of opening one per call.
if (ownStore && loaded && ref.appId === currentAppId && ref.path === getDbPath()) {
@@ -547,11 +548,6 @@ function readStoreActiveRows(
ref: StoreFileRef,
hint?: { rootMessageId?: string; chatScopeChatId?: string; threadScopeChatId?: string },
): Session[] {
- if (ref.kind === 'json') {
- const parsed = JSON.parse(readFileSync(ref.path, 'utf-8')) as unknown;
- if (!parsed || typeof parsed !== 'object') return [];
- return Object.values(parsed as Record).filter(s => s?.status === 'active');
- }
const db = openDbForRead(ref.path);
try {
let sql = "SELECT row FROM sessions WHERE status = 'active'";
@@ -632,11 +628,6 @@ function remoteOwnersEqual(left: RemoteDurableOwner, right: RemoteDurableOwner):
&& left.backendType === right.backendType;
}
-let testOnlyAfterRemoteBatchRename: (() => void) | undefined;
-export function __testOnly_setAfterRemoteBatchRename(hook: (() => void) | undefined): void {
- testOnlyAfterRemoteBatchRename = hook;
-}
-
/**
* Initialise session store for a specific bot (multi-daemon mode).
* When appId is set, sessions are stored in `session-stores/{appId}/sessions.db`.
@@ -1215,32 +1206,6 @@ function recoverPoisonedSqliteStore(dbFp: string, jsonFp: string): {
return { merged, archivedEvidence };
}
-/** Read this store's pre-SQLite JSON into the in-memory projection WITHOUT
- * writing anything back. Only for a non-owning process during the upgrade
- * window (see `load()`); the owning daemon imports instead. */
-function loadFromFrozenJson(): void {
- const jsonFp = getImportJsonPath();
- const legacyFp = join(config.session.dataDir, 'sessions.json');
- const sourceFp = existsSync(jsonFp) ? jsonFp
- : currentAppId && existsSync(legacyFp) ? legacyFp
- : undefined;
- sessions = new Map();
- if (!sourceFp) return;
- try {
- const data = parseSessionsProjectionStrict(readFileSync(sourceFp, 'utf-8'), sourceFp);
- for (const [key, value] of Object.entries(data)) {
- if (sourceFp === legacyFp && value?.larkAppId !== currentAppId) continue;
- repairMissingChatScope(value);
- sessions.set(key, value);
- }
- logger.info(`Loaded ${sessions.size} sessions from ${sourceFp} (store not imported yet)`);
- } catch (err) {
- logger.error(`Failed to load sessions: ${err}`);
- loadFailure = err instanceof Error ? err : new Error(String(err));
- sessions = new Map();
- }
-}
-
// Sessions persisted before 2026-04-29 lack `cliId`; consumers must fall back to 'unknown' at the render boundary.
function load(): void {
if (loaded) return;
@@ -1300,13 +1265,14 @@ function load(): void {
if (!existsSync(dbFp)) {
if (!sqliteBootstrapAllowed) {
- // `owner: false` (a worker) and no store yet: the daemon that spawned it
- // still runs the pre-SQLite build and keeps writing its JSON, so this
- // process reads THAT — creating a .db behind that daemon's back would
- // fork the two representations. Read-only: the repairs and the
- // legacy→per-bot migration are the owning daemon's job, and it does them
- // once, as the import.
- loadFromFrozenJson();
+ // `owner: false` (a worker) must not create a .db or parse frozen JSON.
+ // A leftover sessions-*.json without a .db is unmigrated — fail closed.
+ if (classifyStorePresence(currentAppId, config.session.dataDir) === 'unmigrated') {
+ loadFailure = new SessionStoreUnmigratedError(
+ `会话库尚未迁移到 SQLite(${storeJsonFileName(currentAppId)} 仍在,sessions.db 不存在)`,
+ );
+ }
+ sessions = new Map();
loaded = true;
return;
}
@@ -1600,7 +1566,6 @@ export function persistActiveRemoteLineagesExactBatch(
}
if (changed) {
published = true;
- testOnlyAfterRemoteBatchRename?.();
}
// Read back the committed rows before any worker may exit.
@@ -1719,6 +1684,7 @@ export function createSession(
export function getSession(sessionId: string): Session | undefined {
load();
+ if (loadFailure) throw new SessionStoreUnavailableError(loadFailure);
return sessions.get(sessionId) ?? findInOtherFiles(sessionId);
}
@@ -1754,30 +1720,20 @@ export function getOwnedSession(sessionId: string): Session | undefined {
}
/** Cross-process fresh read. SQLite: a point SELECT observes the last committed
- * write (WAL orders the daemon against offline CLI writers). JSON (a store the
- * owning daemon has not imported yet): ordered after writers by the shared file
- * lock, as before. */
+ * write (WAL orders the daemon against offline CLI writers). */
export function getSessionFresh(sessionId: string): Session | undefined {
+ load();
+ if (loadFailure) throw new SessionStoreUnavailableError(loadFailure);
ensureDir();
const dbFp = getDbPath();
- if (existsSync(dbFp)) {
- try {
- return readStoreRowByKey({ appId: currentAppId, kind: 'sqlite', path: dbFp }, sessionId);
- } catch (err) {
- if (err instanceof SessionStoreSqliteUnavailableError) throw err;
- return undefined;
- }
+ if (!existsSync(dbFp)) return undefined;
+ try {
+ return readStoreRowByKey({ appId: currentAppId, path: dbFp }, sessionId);
+ } catch (err) {
+ if (err instanceof SessionStoreSqliteUnavailableError) throw err;
+ if (loadFailure) throw new SessionStoreUnavailableError(loadFailure);
+ return undefined;
}
- const fp = getImportJsonPath();
- return withFileLockSync(fp, () => {
- if (!existsSync(fp)) return undefined;
- try {
- const data = JSON.parse(readFileSync(fp, 'utf-8')) as Record;
- return data[sessionId];
- } catch {
- return undefined;
- }
- });
}
/**
@@ -2371,20 +2327,22 @@ export function listSessionsStrict(): Session[] {
/** Read-only configuration-change guard; unlike display snapshots, malformed rows fail closed. */
export function readBotSessionsStrict(appId: string, dataDir = config.session.dataDir): Session[] {
+ if (classifyStorePresence(appId, dataDir) === 'unmigrated') {
+ throw new SessionStoreUnmigratedError(
+ `会话库尚未迁移到 SQLite(${storeJsonFileName(appId)} 仍在,sessions.db 不存在)`,
+ );
+ }
const result: Session[] = [];
- for (const id of [undefined, appId]) {
+ const seen = new Set();
+ // A+B still has the flat store; include it so pre-split rows for this bot stay visible.
+ for (const id of [undefined, appId] as const) {
const ref = resolveStoreFile(id, dataDir);
- if (!existsSync(ref.path)) continue;
- if (ref.kind === 'json') {
- const parsed = JSON.parse(readFileSync(ref.path, 'utf8')) as Record;
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid session store');
- result.push(...Object.values(parsed).filter(s => id === appId || s.larkAppId === appId));
- } else {
- const db = openDbForRead(ref.path);
- try {
- const rows = db.prepare('SELECT row FROM sessions').all() as { row: string }[];
- result.push(...rows.map(row => JSON.parse(row.row) as Session).filter(s => id === appId || s.larkAppId === appId));
- } finally { db.close(); }
+ if (!ref) continue;
+ for (const [, session] of readStoreEntries(ref)) {
+ if (!(id === appId || session.larkAppId === appId)) continue;
+ if (seen.has(session.sessionId)) continue;
+ seen.add(session.sessionId);
+ result.push(session);
}
}
return result;
@@ -2459,17 +2417,12 @@ export function countActiveSessionsOnDisk(dataDir: string = config.session.dataD
let n = 0;
for (const ref of refs) {
try {
- if (ref.kind === 'sqlite') {
- const db = openDbForRead(ref.path);
- try {
- const hit = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE status = 'active'").get() as { n: number };
- n += hit.n;
- } finally {
- db.close();
- }
- } else {
- const data: Record = JSON.parse(readFileSync(ref.path, 'utf-8'));
- for (const s of Object.values(data)) if (s?.status === 'active') n++;
+ const db = openDbForRead(ref.path);
+ try {
+ const hit = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE status = 'active'").get() as { n: number };
+ n += hit.n;
+ } finally {
+ db.close();
}
} catch (err) {
if (err instanceof SessionStoreSqliteUnavailableError) throw err;
@@ -2519,33 +2472,42 @@ export function collectBotmuxSessionIdentities(dataDir: string = config.session.
// ─── Cross-process offline access ───────────────────────────────────────────
// The only sanctioned ways to touch session rows from OUTSIDE the owning
-// daemon process (agent-facing CLI subcommands, caller-identity proofs). Until
-// 2026-08 the CLI kept its own parallel copies of these (loadSessions /
-// saveSession / mutateSessionOffline in cli.ts) — one of which wrote the whole
-// file WITHOUT the lock; they were absorbed here so persistence mechanics
-// (store layout, lock/transaction, legacy-field strip) stay private to this
-// module. Every entry point resolves each store as db-else-json (mixed
-// upgrade window: npm already replaced dist, daemon still running old code).
+// daemon process (agent-facing CLI subcommands, caller-identity proofs).
+// Cross-process readers only open SQLite stores. A leftover
+// `sessions-.json` without a `.db` is `unmigrated` (existence only).
+
+export type SessionsSnapshot = Map & { unmigratedAppIds: string[] };
+
+function emptySnapshot(unmigratedAppIds: string[]): SessionsSnapshot {
+ const out = new Map() as SessionsSnapshot;
+ out.unmigratedAppIds = unmigratedAppIds;
+ return out;
+}
/**
* Read-only snapshot of every session row across the legacy store and all
- * per-bot stores. Per-bot rows win duplicate sessionIds and get `larkAppId`
- * stamped from their filename so a later offline mutation resolves the owning
- * store. Deliberately lock-free: atomic publication (tmp+rename for JSON,
- * WAL transactions for SQLite) keeps each store self-consistent, and snapshot
- * composition must stay a pure reader (an older CLI opportunistically migrated
- * legacy rows here, which made even `botmux list` a whole-file writer able to
- * race a daemon save).
+ * per-bot SQLite stores. Per-bot rows win duplicate sessionIds and get
+ * `larkAppId` stamped from their filename so a later offline mutation
+ * resolves the owning store. Deliberately lock-free: WAL transactions keep
+ * each store self-consistent.
*/
export function loadAllSessionsSnapshot(options: {
dataDir?: string;
/** Per-bot fallback when the data dir cannot be enumerated (the CLI file
* sandbox exposes this bot's own store but NOT a listing of data/). */
fallbackAppId?: string;
-} = {}): Map {
+ /** When given, only these app ids (plus `fallbackAppId`) can be reported as
+ * `unmigrated`. A leftover JSON of a bot that no longer exists is abandoned
+ * data, not a pending migration — see `services/known-bot-app-ids.ts`. */
+ knownAppIds?: ReadonlySet;
+} = {}): SessionsSnapshot {
const dataDir = options.dataDir ?? config.session.dataDir;
- const out = new Map();
- const readInto = (ref: StoreFileRef): void => {
+ const out = emptySnapshot([]);
+ const onlyKnown = (ids: string[]): string[] => options.knownAppIds
+ ? ids.filter(id => options.knownAppIds!.has(id) || id === options.fallbackAppId)
+ : ids;
+ const readInto = (ref: StoreFileRef | undefined): void => {
+ if (!ref) return;
let entries: [string, Session][];
try {
entries = readStoreEntries(ref);
@@ -2553,9 +2515,6 @@ export function loadAllSessionsSnapshot(options: {
if (err instanceof SessionStoreSqliteUnavailableError) throw err;
return; /* absent or corrupt store → skip */
}
- // Arrays are deliberately tolerated on the JSON side (Object.entries
- // yields their rows): the historical CLI loader accepted array-shaped
- // files and existing fixtures/tools rely on that.
for (const [, value] of entries) {
const session = value as Session;
if (!session || typeof session !== 'object' || !session.sessionId) continue;
@@ -2564,19 +2523,23 @@ export function loadAllSessionsSnapshot(options: {
out.set(session.sessionId, session);
}
};
- readInto(resolveStoreFile(undefined, dataDir));
let refs: StoreFileRef[];
try {
refs = listStoreRefs(dataDir);
+ out.unmigratedAppIds = onlyKnown(listUnmigratedAppIds(dataDir));
} catch {
if (options.fallbackAppId) {
readInto(resolveStoreFile(options.fallbackAppId, dataDir));
+ out.unmigratedAppIds =
+ classifyStorePresence(options.fallbackAppId, dataDir) === 'unmigrated'
+ ? [options.fallbackAppId]
+ : [];
}
return out;
}
- for (const ref of refs) {
- if (ref.appId) readInto(ref);
- }
+ const flat = refs.filter(ref => !ref.appId);
+ const perBot = refs.filter(ref => ref.appId);
+ for (const ref of [...flat, ...perBot]) readInto(ref);
return out;
}
@@ -2596,7 +2559,7 @@ export function readSessionRowFromDisk(
? [resolveStoreFile(larkAppId, dataDir), resolveStoreFile(undefined, dataDir)]
: [resolveStoreFile(undefined, dataDir)];
for (const ref of stores) {
- if (!existsSync(ref.path)) continue;
+ if (!ref || !existsSync(ref.path)) continue;
try {
const hit = readStoreRowByKey(ref, sessionId);
if (hit) return hit;
@@ -2609,73 +2572,54 @@ export function readSessionRowFromDisk(
}
/**
- * Fail-closed identity scan: every store's copy of one session row across the
- * legacy and all per-bot stores — one entry per store that holds the id (a
- * per-bot store is its .db when that exists, else its .json; a frozen
- * pre-import JSON file is superseded, not a second copy). An unlistable data
- * dir THROWS: a caller proving "this row resolves exactly once" must not
- * mistake an unreadable store for an empty one. A corrupt individual store is
- * skipped: an unrelated bot's bad file must neither block nor impersonate a
- * valid record; the target row still has to resolve from a readable store.
+ * Fail-closed identity scan: every SQLite store's copy of one session row.
+ * An unlistable data dir THROWS. A corrupt individual store increments
+ * `unreadableStores` and is skipped so an unrelated bot's bad file can
+ * neither block nor impersonate a valid record.
*/
export function readSessionRowCopiesAcrossStores(
sessionId: string,
dataDir: string = config.session.dataDir,
-): Session[] {
+): { matches: Session[]; unreadableStores: number } {
const refs = listStoreRefs(dataDir, { strict: true });
const matches: Session[] = [];
+ let unreadableStores = 0;
for (const ref of refs) {
let session: Session | undefined;
try {
session = readStoreRowByKey(ref, sessionId);
} catch (err) {
if (err instanceof SessionStoreSqliteUnavailableError) throw err;
+ unreadableStores++;
continue;
}
if (!session || typeof session !== 'object' || Array.isArray(session)) continue;
if (session.sessionId !== sessionId) continue;
matches.push(session);
}
- return matches;
+ return { matches, unreadableStores };
}
// ─── Temporary host activation (daemon absent) ──────────────────────────────
//
// A process that owns no store may still act on one exact row while no daemon
-// holds it: `botmux delete` / `list` auto-prune / `whiteboard` from a host
-// shell, and the dashboard's board deletion. The activation is ONE exclusive
-// store transaction — the SQLite `BEGIN IMMEDIATE`, or the shared file lock of
-// a store still on JSON (upgrade window) — inside which ownership is judged,
-// the FRESH row is read, the shared command apply (session-commands.ts) runs,
-// and the row is published. Nothing else is expressible here: there is no
-// closure that could write an arbitrary field list.
+// holds it. The activation is ONE exclusive SQLite `BEGIN IMMEDIATE` inside
+// which ownership is judged, the FRESH row is read, the shared command apply
+// runs, and the row is published.
//
-// SQLite ownership is the occupancy row read in this same transaction. A live
-// lease yields. Without one (row absent or expired) `abortIf` — the
-// descriptor-heartbeat probe, also a test hook — still decides; that is the
-// upgrade window for daemons that write SQLite but not occupancy. `abortIf`
-// is evaluated at entry and again immediately before publication (the lease
-// row itself cannot change under this transaction). JSON stores use `abortIf`
-// only (no occupancy table). SQLite's own locking does NOT replace occupancy:
-// it orders writers, but cannot detect that a daemon holding a stale
-// in-memory cache has come alive.
+// Ownership: a live lease (TTL or live ownerPid) yields `owned{lease}`.
+// Without one, `probeHolder` (fresh descriptor) still refuses — it never
+// permits a write. Probe once, before `openDbForOwnStore`, so a v3.18.x
+// store is not given an occupancy table just to be rejected. Inside the
+// write transaction the lease is re-read (race); the probe is not repeated.
//
-// No lease row is written by the temporary host: a claim + release inside a
-// single exclusive transaction is unobservable to every other connection, and
-// holding one ACROSS the steps of a multi-step command (the offline abandon)
-// would only leave a daemon that boots meanwhile `displaced` until its next
-// heartbeat tick. Each step re-judges ownership in its own transaction.
+// No lease row is written by the temporary host.
/** Why the activation yielded without touching the row. */
export type UnownedRowBlocked =
- /** A live lease, or a fresh heartbeat while no live lease exists, holds the store. */
- | { outcome: 'owned' }
- /** No such row — or no store file at all (never created here: an empty
- * store would disable the daemon's one-shot JSON import gate). */
+ | { outcome: 'owned'; heldBy: HolderReason }
| { outcome: 'missing' }
- /** The store's write lock could not be taken (another writer holds it past
- * busy_timeout / the file-lock wait). Same "do not publish" as `owned`;
- * reported apart so a caller never claims a live row is gone. */
+ | { outcome: 'unmigrated' }
| { outcome: 'contended' };
export type UnownedRowRead =
@@ -2688,90 +2632,69 @@ export type UnownedRowApply =
| { outcome: 'refused'; reason: SessionRowRefusal | 'row_changed'; row: Session }
| UnownedRowBlocked;
-type UnownedRowOptions = { dataDir?: string; abortIf?: () => boolean };
+type UnownedRowOptions = {
+ dataDir?: string;
+ probeHolder?: () => HolderReason | undefined;
+};
/** One step over the fresh row: whether to publish it, and what to report. */
type UnownedRowStep = (current: Session) => { publish: boolean; result: T };
+function owned(heldBy: HolderReason): UnownedRowBlocked {
+ return { outcome: 'owned', heldBy };
+}
+
function runUnownedRowTxn(
target: { sessionId: string; larkAppId?: string },
options: UnownedRowOptions,
step: UnownedRowStep,
): T | UnownedRowBlocked {
const dataDir = options.dataDir ?? config.session.dataDir;
- const ref = resolveStoreFile(target.larkAppId, dataDir);
-
- if (ref.kind === 'sqlite') {
- // resolveStoreFile already probed existsSync, but a read-write open CREATES
- // a missing file. The window between that probe and this open must not
- // plant an empty store: that would make the daemon's import gate skip the
- // one-shot JSON import and silently drop every pre-SQLite row.
- if (!existsSync(ref.path)) return { outcome: 'missing' };
- let db: SqliteDatabaseLike | undefined;
- let inTxn = false;
- try {
- // openDbForOwnStore (schema ensure) and BEGIN IMMEDIATE both take the
- // write lock. Contention here is "someone else is publishing", not a
- // broken store — same yield as a live occupancy row.
- db = openDbForOwnStore(ref.path);
- db.exec('BEGIN IMMEDIATE');
- inTxn = true;
- const lease = readOccupancyInTxn(db);
- if (sqliteOccupancyBlocksWrite(lease, Date.now(), options.abortIf)) return { outcome: 'owned' };
- const hit = db.prepare('SELECT row FROM sessions WHERE session_id = ?')
- .get(target.sessionId) as { row: string } | undefined;
- if (!hit) return { outcome: 'missing' };
- const current = JSON.parse(hit.row) as Session;
- const { publish, result } = step(current);
- if (!publish) return result;
- if (sqliteOccupancyBlocksWrite(lease, Date.now(), options.abortIf)) return { outcome: 'owned' };
- db.prepare('UPDATE sessions SET status = ?, row = ? WHERE session_id = ?')
- .run(sessionStatusText(current), JSON.stringify(current), target.sessionId);
- db.exec('COMMIT');
- inTxn = false;
- return result;
- } catch (err) {
- if (isTransientStoreContentionError(err)) return { outcome: 'contended' };
- throw err;
- } finally {
- if (inTxn) { try { db?.exec('ROLLBACK'); } catch { /* txn already gone */ } }
- try { db?.close(); } catch { /* already closed */ }
- }
+ const presence = classifyStorePresence(target.larkAppId, dataDir);
+ if (presence === 'unmigrated') return { outcome: 'unmigrated' };
+ if (presence === 'absent') return { outcome: 'missing' };
+
+ const dbPath = storeDbPath(target.larkAppId, dataDir);
+ let lease: OccupancyLease | undefined;
+ try {
+ const readDb = openDbForRead(dbPath);
+ try { lease = readOccupancyInTxn(readDb); }
+ finally { readDb.close(); }
+ } catch (err) {
+ if (isTransientStoreContentionError(err)) return { outcome: 'contended' };
+ return owned('store_unreadable');
}
- // Upgrade window: this store's owning daemon still runs the pre-SQLite build
- // and keeps writing the JSON, so an offline command has to land there too —
- // creating a .db here would fork the two representations behind that daemon's
- // back. Same file lock the old build takes.
- const fp = ref.path;
+ const held = holderFromLease(lease, Date.now()) ?? options.probeHolder?.();
+ if (held) return owned(held);
+
+ let db: SqliteDatabaseLike | undefined;
+ let inTxn = false;
try {
- return withFileLockSync(fp, (): T | UnownedRowBlocked => {
- if (options.abortIf?.()) return { outcome: 'owned' };
- let data: Record = {};
- if (existsSync(fp)) {
- try { data = JSON.parse(readFileSync(fp, 'utf-8')); } catch { /* start fresh */ }
- }
- const current = data[target.sessionId];
- if (!current) return { outcome: 'missing' };
- const { publish, result } = step(current);
- if (!publish) return result;
- data[target.sessionId] = current;
- for (const [key, val] of Object.entries(data)) {
- if (val && typeof val === 'object' && 'sessionId' in val && (val as Session).sessionId !== key) {
- delete data[key];
- continue;
- }
- if (val && typeof val === 'object') stripLegacyPendingCardFields(val as unknown as Record);
- }
- if (options.abortIf?.()) return { outcome: 'owned' };
- const tmpFp = `${fp}.${process.pid}.${randomUUID()}.tmp`;
- writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
- renameSync(tmpFp, fp);
- return result;
- });
+ db = openDbForOwnStore(dbPath);
+ db.exec('BEGIN IMMEDIATE');
+ inTxn = true;
+ const leaseInTxn = readOccupancyInTxn(db);
+ const raced = holderFromLease(leaseInTxn, Date.now());
+ if (raced) return owned(raced);
+ const hit = db.prepare('SELECT row FROM sessions WHERE session_id = ?')
+ .get(target.sessionId) as { row: string } | undefined;
+ if (!hit) return { outcome: 'missing' };
+ const current = JSON.parse(hit.row) as Session;
+ const { publish, result } = step(current);
+ if (!publish) return result;
+ db.prepare('UPDATE sessions SET status = ?, row = ? WHERE session_id = ?')
+ .run(sessionStatusText(current), JSON.stringify(current), target.sessionId);
+ db.exec('COMMIT');
+ inTxn = false;
+ return result;
} catch (err) {
if (isTransientStoreContentionError(err)) return { outcome: 'contended' };
+ if (err instanceof SessionStoreSqliteUnavailableError) return owned('store_unreadable');
throw err;
+ } finally {
+ if (inTxn) { try { db?.exec('ROLLBACK'); } catch { /* txn already gone */ } }
+ try { db?.close(); } catch { /* already closed */ }
}
}
diff --git a/src/services/whiteboard-store.ts b/src/services/whiteboard-store.ts
index 7027899253..8e4a5d48e0 100644
--- a/src/services/whiteboard-store.ts
+++ b/src/services/whiteboard-store.ts
@@ -10,6 +10,8 @@ import { fetchDaemonIpc, loadDaemonIpcSecret } from '../core/daemon-ipc-auth.js'
import { findOnlineDaemon } from '../utils/daemon-discovery.js';
import { loadAllSessionsSnapshot } from './session-store.js';
import { applySessionCommandAsHost } from './session-command-host.js';
+import { formatStoreHoldMessage, formatUnmigratedMessage } from './session-store-copy.js';
+import { knownBotAppIds } from './known-bot-app-ids.js';
export type WhiteboardScope = 'chat' | 'project' | 'custom';
@@ -441,6 +443,7 @@ const UNBIND_IPC_TIMEOUT_MS = 5_000;
* visible but unusable (writing behind its live cache is not allowed), or the
* row was gone by the time the write ran. */
type UnbindOutcome = 'cleared' | 'already_changed' | 'unresolved';
+type UnbindResult = { status: UnbindOutcome; reason?: string };
/**
* Clear one session's binding to a board that is being deleted.
@@ -459,7 +462,7 @@ async function unbindSessionWhiteboard(
session: SessionWhiteboardRef,
boardId: string,
dataDir: string,
-): Promise {
+): Promise {
const larkAppId = session.larkAppId;
if (larkAppId) {
try {
@@ -476,12 +479,12 @@ async function unbindSessionWhiteboard(
},
loadDaemonIpcSecret(),
);
- // 409 is the daemon reporting a different binding — authoritative, and
- // not something the offline path should try to overrule.
- if (res.status === 409) return 'already_changed';
- if (res.ok) return 'cleared';
+ // Any HTTP answer is terminal — the daemon is alive and authoritative.
+ if (res.status === 409) return { status: 'already_changed' };
+ if (res.ok) return { status: 'cleared' };
+ return { status: 'unresolved' };
}
- } catch { /* fall through: the re-probe below decides whether we may write */ }
+ } catch { /* connection failed: the re-probe below decides whether we may write */ }
}
const published = applySessionCommandAsHost(
{ sessionId: session.sessionId, ...(larkAppId ? { larkAppId } : {}) },
@@ -489,13 +492,16 @@ async function unbindSessionWhiteboard(
{ dataDir },
);
switch (published.outcome) {
- case 'applied': return 'cleared';
- // The fresh row no longer points at this board (or already dropped it).
+ case 'applied': return { status: 'cleared' };
case 'noop':
- case 'refused': return 'already_changed';
+ case 'refused': return { status: 'already_changed' };
case 'owned':
+ return { status: 'unresolved', reason: formatStoreHoldMessage(published.heldBy) };
+ case 'unmigrated':
+ return { status: 'unresolved', reason: formatUnmigratedMessage() };
case 'missing':
- case 'contended': return 'unresolved';
+ case 'contended':
+ return { status: 'unresolved' };
}
}
@@ -509,23 +515,28 @@ async function unbindSessionWhiteboard(
*/
async function clearSessionWhiteboardRefs(
id: string,
-): Promise<{ cleared: number; unresolved: number }> {
+): Promise<{ cleared: number; unresolved: number; reasons: string[] }> {
const dataDir = config.session.dataDir;
- let snapshot: Map;
+ let snapshot: ReturnType;
try {
- snapshot = loadAllSessionsSnapshot({ dataDir }) as unknown as Map;
- } catch { return { cleared: 0, unresolved: 0 }; }
+ snapshot = loadAllSessionsSnapshot({ dataDir, knownAppIds: knownBotAppIds({ dataDir }) });
+ } catch { return { cleared: 0, unresolved: 0, reasons: [] }; }
+ const reasons: string[] = [];
+ if (snapshot.unmigratedAppIds.length > 0) {
+ reasons.push(formatUnmigratedMessage());
+ }
let cleared = 0;
let unresolved = 0;
for (const session of snapshot.values()) {
if (session?.whiteboardId !== id) continue;
- let outcome: UnbindOutcome;
- try { outcome = await unbindSessionWhiteboard(session, id, dataDir); }
- catch { outcome = 'unresolved'; }
- if (outcome === 'cleared') cleared++;
- else if (outcome === 'unresolved') unresolved++;
+ let result: UnbindResult;
+ try { result = await unbindSessionWhiteboard(session as unknown as SessionWhiteboardRef, id, dataDir); }
+ catch { result = { status: 'unresolved' }; }
+ if (result.reason && !reasons.includes(result.reason)) reasons.push(result.reason);
+ if (result.status === 'cleared') cleared++;
+ else if (result.status === 'unresolved') unresolved++;
}
- return { cleared, unresolved };
+ return { cleared, unresolved, reasons };
}
/**
@@ -538,7 +549,7 @@ async function clearSessionWhiteboardRefs(
*/
export async function deleteWhiteboard(
id: string,
-): Promise<{ ok: true; id: string; clearedSessions: number; unresolvedSessions: number }> {
+): Promise<{ ok: true; id: string; clearedSessions: number; unresolvedSessions: number; reasons?: string[] }> {
const clean = safeId(id);
withIndexLock(() => {
const index = readIndex();
@@ -558,8 +569,14 @@ export async function deleteWhiteboard(
});
// Outside the index lock: unbinding awaits daemon IPC, and the lock is
// synchronous. Nothing here reads the index.
- const { cleared, unresolved } = await clearSessionWhiteboardRefs(clean);
- return { ok: true, id: clean, clearedSessions: cleared, unresolvedSessions: unresolved };
+ const { cleared, unresolved, reasons } = await clearSessionWhiteboardRefs(clean);
+ return {
+ ok: true,
+ id: clean,
+ clearedSessions: cleared,
+ unresolvedSessions: unresolved,
+ ...(reasons.length > 0 ? { reasons } : {}),
+ };
}
export function whiteboardPath(id: string): { dir: string; board: string; log: string; meta: string } {
diff --git a/src/utils/daemon-discovery.ts b/src/utils/daemon-discovery.ts
index db259d610c..2b22b26398 100644
--- a/src/utils/daemon-discovery.ts
+++ b/src/utils/daemon-discovery.ts
@@ -23,6 +23,10 @@ export interface OnlineDaemonInfo {
bootInstanceId?: string;
/** Auth protocol advertised atomically with bootInstanceId + ipcPort. */
workflowIpcProtocol?: string;
+ /** Presence-based session-store capability. Copy only; never a write permit. */
+ sessionStoreProtocol?: string;
+ /** Running binary version. Copy only; never compared by size. */
+ botmuxVersion?: string;
botName?: string;
cliId?: string;
pid?: number;
@@ -84,6 +88,12 @@ export function listOnlineDaemons(dataDir?: string): OnlineDaemonInfo[] {
...(typeof d.workflowIpcProtocol === 'string' && d.workflowIpcProtocol
? { workflowIpcProtocol: d.workflowIpcProtocol }
: {}),
+ ...(typeof d.sessionStoreProtocol === 'string' && d.sessionStoreProtocol
+ ? { sessionStoreProtocol: d.sessionStoreProtocol }
+ : {}),
+ ...(typeof d.botmuxVersion === 'string' && d.botmuxVersion
+ ? { botmuxVersion: d.botmuxVersion }
+ : {}),
...(typeof d.botName === 'string' && d.botName.trim() ? { botName: d.botName.trim() } : {}),
...(typeof d.cliId === 'string' && d.cliId.trim() ? { cliId: d.cliId.trim() } : {}),
pid: d.pid,
diff --git a/src/utils/daemon-version-display.ts b/src/utils/daemon-version-display.ts
new file mode 100644
index 0000000000..c7727a1caa
--- /dev/null
+++ b/src/utils/daemon-version-display.ts
@@ -0,0 +1,68 @@
+/**
+ * Operator-facing copy for "running daemon version vs disk version".
+ *
+ * Presence-based only: compare equality, never semver precedence. `0.0.0` (source
+ * checkout / compiled binary without a baked version) is undetermined and must
+ * not claim that anyone is ahead or behind.
+ */
+export const UNDETERMINED_BOTMUX_VERSION = '0.0.0';
+
+export const SESSION_STORE_PROTOCOL = 'occupancy-v1' as const;
+export type SessionStoreProtocol = typeof SESSION_STORE_PROTOCOL;
+
+export function isComparableBotmuxVersion(version: string | undefined | null): version is string {
+ const v = version?.trim();
+ return !!v && v !== UNDETERMINED_BOTMUX_VERSION;
+}
+
+export function stripBotmuxVersionPrefix(version: string): string {
+ return version.trim().replace(/^v/i, '');
+}
+
+/** True only when both sides are comparable and the strings differ. */
+export function daemonVersionDiffersFromDisk(
+ running: string | undefined | null,
+ disk: string | undefined | null,
+): boolean {
+ return isComparableBotmuxVersion(running)
+ && isComparableBotmuxVersion(disk)
+ && running !== disk;
+}
+
+/** Shared operator sentence for history staleHint and the version card. */
+export function formatDaemonVersionRestartHint(running: string, disk: string): string {
+ return `运行中的 daemon v${stripBotmuxVersionPrefix(running)} 与磁盘 v${stripBotmuxVersionPrefix(disk)} 不一致,运行 botmux restart 应用`;
+}
+
+export type DaemonVersionGroup = { version: string; count: number };
+
+export function groupDaemonVersions(
+ versions: Array,
+): DaemonVersionGroup[] {
+ const counts = new Map();
+ for (const raw of versions) {
+ const key = isComparableBotmuxVersion(raw) ? raw : UNDETERMINED_BOTMUX_VERSION;
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .map(([version, count]) => ({ version, count }))
+ .sort((a, b) => a.version.localeCompare(b.version));
+}
+
+/**
+ * Footer / update-card line when at least one running daemon differs from disk.
+ * Groups that are `0.0.0` or missing are omitted (undetermined, not a mismatch).
+ */
+export function formatRunningDaemonsRestartSummary(
+ versions: Array,
+ disk: string | undefined | null,
+): string | undefined {
+ if (!isComparableBotmuxVersion(disk)) return undefined;
+ const mismatched = groupDaemonVersions(versions)
+ .filter(g => daemonVersionDiffersFromDisk(g.version, disk));
+ if (mismatched.length === 0) return undefined;
+ const parts = mismatched.map(g => `${g.count} 个 v${stripBotmuxVersionPrefix(g.version)}`).join(' / ');
+ return `运行中的 daemon:${parts}(磁盘 v${stripBotmuxVersionPrefix(disk)}),运行 botmux restart 应用`;
+}
+
+export const INSTALL_RESTART_HINT = '若 daemon 正在运行,请执行 botmux restart 应用新版本';
diff --git a/src/worker.ts b/src/worker.ts
index 4963c865b0..8945372624 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -19449,8 +19449,8 @@ process.on('message', async (raw: unknown) => {
// must not be appended to the bot's chat-session registry. The
// workflow's own event log is the source of truth for run state.
if (msg.larkAppId && process.env.BOTMUX_WORKFLOW !== '1') {
- // owner:false —— worker 可能由仍在跑旧代码的 daemon 从新 dist spawn 出来,
- // 不许它首启导入/建 .db(引擎切换只能由 daemon 自己做),只按 db-else-json 读。
+ // owner:false —— worker 不得首启导入/建 .db(引擎切换只能由 daemon 自己做)。
+ // 无 .db 而有 leftover JSON 时 load() 记为 unmigrated,getSession 抛错。
sessionStore.init(msg.larkAppId, { owner: false });
}
if (msg.cliId === 'codex-app') {
diff --git a/test/cli-send-hook-context.test.ts b/test/cli-send-hook-context.test.ts
index 751fb1c0ae..ce078fe95e 100644
--- a/test/cli-send-hook-context.test.ts
+++ b/test/cli-send-hook-context.test.ts
@@ -65,14 +65,14 @@ describe('cmdSend hook context wiring', () => {
expect(cmdSend).toContain('envSessionId=${process.env.BOTMUX_SESSION_ID ??');
expect(cmdSend).toContain('envLarkAppId=${process.env.BOTMUX_LARK_APP_ID ??');
expect(cmdSend).toContain('originSessionId=${originSessionId ??');
- expect(cmdSend).toContain('loadedSessions=${sessions.size}');
+ expect(cmdSend).toContain('reason=${resolved.reason}');
expect(cmdSend).toContain("relayDir=${relayDir ? 'present' : 'absent'}");
expect(cmdSend).toContain('readIsolation=${isolatedSendRequired ?');
expect(cmdSend).toContain("capability=${isolatedCapabilityCtx ? 'present' : 'absent'}");
const diagnosticStart = cmdSend.indexOf('session_lookup_miss');
expect(diagnosticStart).toBeGreaterThanOrEqual(0);
- const missingSessionAt = cmdSend.indexOf('未找到 session', diagnosticStart);
+ const missingSessionAt = cmdSend.indexOf('console.error(resolved.message)', diagnosticStart);
expect(missingSessionAt).toBeGreaterThan(diagnosticStart);
const diagnosticBlock = cmdSend.slice(diagnosticStart, missingSessionAt);
@@ -83,7 +83,7 @@ describe('cmdSend hook context wiring', () => {
"process.env.BOTMUX_SESSION_ID ?? '-'",
"process.env.BOTMUX_LARK_APP_ID ?? '-'",
"originSessionId ?? '-'",
- 'sessions.size',
+ 'resolved.reason',
"relayDir ? 'present' : 'absent'",
"isolatedSendRequired ? 'required' : kernelReadIsolationDetected ? 'detected' : 'off'",
"isolatedCapabilityCtx ? 'present' : 'absent'",
diff --git a/test/daemon-discovery.test.ts b/test/daemon-discovery.test.ts
index 5963e91714..3b1f8aadea 100644
--- a/test/daemon-discovery.test.ts
+++ b/test/daemon-discovery.test.ts
@@ -79,6 +79,25 @@ describe('daemon discovery', () => {
})]);
expect(listOnlineDaemons()[0]).not.toHaveProperty('bootInstanceId');
expect(listOnlineDaemons()[0]).not.toHaveProperty('workflowIpcProtocol');
+ expect(listOnlineDaemons()[0]).not.toHaveProperty('sessionStoreProtocol');
+ expect(listOnlineDaemons()[0]).not.toHaveProperty('botmuxVersion');
+ });
+
+ it('passes through sessionStoreProtocol and botmuxVersion without inventing them', () => {
+ writeFileSync(join(dir, 'dashboard-daemons', 'new.json'), JSON.stringify({
+ larkAppId: 'new',
+ ipcPort: 7959,
+ sessionStoreProtocol: 'occupancy-v1',
+ botmuxVersion: '3.20.0',
+ lastHeartbeat: Date.now(),
+ }));
+
+ expect(listOnlineDaemons()).toEqual([expect.objectContaining({
+ larkAppId: 'new',
+ ipcPort: 7959,
+ sessionStoreProtocol: 'occupancy-v1',
+ botmuxVersion: '3.20.0',
+ })]);
});
it('follows the canonical data-dir breadcrumb when SESSION_DATA_DIR is absent', () => {
diff --git a/test/daemon-version-display.test.ts b/test/daemon-version-display.test.ts
new file mode 100644
index 0000000000..fc29fa3556
--- /dev/null
+++ b/test/daemon-version-display.test.ts
@@ -0,0 +1,51 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import {
+ INSTALL_RESTART_HINT,
+ daemonVersionDiffersFromDisk,
+ formatDaemonVersionRestartHint,
+ formatRunningDaemonsRestartSummary,
+ groupDaemonVersions,
+ isComparableBotmuxVersion,
+} from '../src/utils/daemon-version-display.js';
+
+describe('daemon version display', () => {
+ it('treats 0.0.0 and missing as incomparable', () => {
+ expect(isComparableBotmuxVersion('0.0.0')).toBe(false);
+ expect(isComparableBotmuxVersion('')).toBe(false);
+ expect(isComparableBotmuxVersion(undefined)).toBe(false);
+ expect(isComparableBotmuxVersion('3.20.0')).toBe(true);
+ });
+
+ it('compares equality only, never size', () => {
+ expect(daemonVersionDiffersFromDisk('3.19.3', '3.20.0')).toBe(true);
+ expect(daemonVersionDiffersFromDisk('3.20.0', '3.20.0')).toBe(false);
+ expect(daemonVersionDiffersFromDisk('3.21.0-canary.1', '3.20.0')).toBe(true);
+ expect(daemonVersionDiffersFromDisk('0.0.0', '3.20.0')).toBe(false);
+ expect(daemonVersionDiffersFromDisk('3.19.3', '0.0.0')).toBe(false);
+ expect(daemonVersionDiffersFromDisk(undefined, '3.20.0')).toBe(false);
+ });
+
+ it('shares one operator sentence for the history hint and the version card', () => {
+ expect(formatDaemonVersionRestartHint('3.19.3', '3.20.0'))
+ .toBe('运行中的 daemon v3.19.3 与磁盘 v3.20.0 不一致,运行 botmux restart 应用');
+ expect(formatRunningDaemonsRestartSummary(['3.19.3', '3.19.3', '3.18.12', '0.0.0'], '3.20.0'))
+ .toBe('运行中的 daemon:1 个 v3.18.12 / 2 个 v3.19.3(磁盘 v3.20.0),运行 botmux restart 应用');
+ expect(formatRunningDaemonsRestartSummary(['3.20.0', '0.0.0'], '3.20.0')).toBeUndefined();
+ expect(formatRunningDaemonsRestartSummary(['3.19.3'], '0.0.0')).toBeUndefined();
+ expect(groupDaemonVersions(['3.19.3', undefined])).toEqual([
+ { version: '0.0.0', count: 1 },
+ { version: '3.19.3', count: 1 },
+ ]);
+ });
+});
+
+describe('install / postinstall restart hint', () => {
+ it('is printed unconditionally by postinstall-bin and install.sh', () => {
+ const postinstall = readFileSync(resolve('scripts/postinstall-bin.mjs'), 'utf8');
+ const installSh = readFileSync(resolve('install.sh'), 'utf8');
+ expect(postinstall).toContain(INSTALL_RESTART_HINT);
+ expect(installSh).toContain(INSTALL_RESTART_HINT);
+ });
+});
diff --git a/test/dashboard-registry.test.ts b/test/dashboard-registry.test.ts
index 86cba13989..5ab5a3cab2 100644
--- a/test/dashboard-registry.test.ts
+++ b/test/dashboard-registry.test.ts
@@ -33,6 +33,22 @@ describe('DaemonRegistry', () => {
expect(reg.getByAppId('appA')?.ipcPort).toBe(7892);
expect(reg.getByAppId('appA')?.bootInstanceId).toBe(bootInstanceId);
expect(reg.getByAppId('appA')?.workflowIpcProtocol).toBe('v1');
+ expect(reg.getByAppId('appA')?.sessionStoreProtocol).toBeUndefined();
+ expect(reg.getByAppId('appA')?.botmuxVersion).toBeUndefined();
+ reg.stop();
+ });
+
+ it('copies sessionStoreProtocol and botmuxVersion from a fresh descriptor', async () => {
+ writeFileSync(join(dir, 'appA.json'), JSON.stringify({
+ larkAppId: 'appA', botName: 'appA', botIndex: 0, ipcPort: 7892,
+ pid: 1, startedAt: Date.now(), lastHeartbeat: Date.now(),
+ sessionStoreProtocol: 'occupancy-v1',
+ botmuxVersion: '3.20.0',
+ }));
+ const reg = new DaemonRegistry(dir);
+ await reg.start();
+ expect(reg.getByAppId('appA')?.sessionStoreProtocol).toBe('occupancy-v1');
+ expect(reg.getByAppId('appA')?.botmuxVersion).toBe('3.20.0');
reg.stop();
});
diff --git a/test/dashboard-update-action.test.ts b/test/dashboard-update-action.test.ts
index 5e8f98f0f8..3249338b85 100644
--- a/test/dashboard-update-action.test.ts
+++ b/test/dashboard-update-action.test.ts
@@ -116,7 +116,7 @@ describe('dashboard update and restart action', () => {
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
- it('flags bootstrap-required when the fleet predates the shutdown protocol', async () => {
+ it('treats a protocol-bootstrap 409 as a generic restart failure', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(json(200, {
ok: true,
@@ -127,7 +127,6 @@ describe('dashboard update and restart action', () => {
.mockResolvedValueOnce(json(409, {
ok: false,
error: 'bootstrap_shutdown_protocol_required',
- unsafeDaemons: ['botmux-local', 'botmux-relay'],
}));
await expect(updateAndRestartBotmux(fetchImpl)).resolves.toEqual({
@@ -135,11 +134,8 @@ describe('dashboard update and restart action', () => {
newVersion: '3.1.0',
changed: true,
restarted: false,
- bootstrapRequired: true,
- unsafeDaemons: ['botmux-local', 'botmux-relay'],
+ restartError: 'bootstrap_shutdown_protocol_required',
});
- // Distinct from a generic restart failure: no restartError is surfaced,
- // so the UI shows the actionable bootstrap message instead.
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
diff --git a/test/fs-policy.test.ts b/test/fs-policy.test.ts
index 59b88797a1..de6ea31186 100644
--- a/test/fs-policy.test.ts
+++ b/test/fs-policy.test.ts
@@ -307,10 +307,9 @@ describe('buildFsPolicy', () => {
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self/sessions.db').access).toBe('readOnly');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self/sessions.db-wal').access).toBe('readOnly');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self').access).toBe('readOnly');
- // …and the bot's OWN pre-SQLite `sessions-.json` is NOT granted any more:
- // it is a one-shot import source the store never reads at runtime, so the
- // allow-list stops covering it (narrower surface, not a weakened assertion).
- expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('readOnly'); // own(升级窗口内仍是唯一可读的会话来源)
+ // the leftover `sessions-.json` is a one-shot import source, never
+ // read at runtime → deliberately NOT granted.
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('none');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/turn-sends/s.jsonl').access).toBe('readWrite'); // OWN session marker only
// blocker #4: turn-sends is granted per-session-FILE, not the whole dir —
// another session's marker is NOT writable (can't corrupt its send-dedup).
@@ -501,9 +500,9 @@ describe('buildFsPolicy', () => {
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self').access).toBe('readOnly');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self/sessions.db').access).toBe('readOnly');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self/sessions.db-shm').access).toBe('readOnly');
- // the legacy `sessions-.json` is a one-shot import source, never read at
- // runtime → deliberately NOT granted (was readOnly before the SQLite-only cut)
- expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('readOnly'); // own(升级窗口内仍是唯一可读的会话来源)
+ // the leftover `sessions-.json` is a one-shot import source, never
+ // read at runtime → deliberately NOT granted.
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('none');
// siblings simply not covered under the allow-list → inaccessible
expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_other.json').access).toBe('none');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_other').access).toBe('none');
@@ -1650,7 +1649,7 @@ describe('no-Lark-transport credential profile (larkTransportEnabled=false)', ()
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_self/sessions.db').access).toBe('readOnly');
// the legacy `sessions-.json` is no longer allow-listed, so under
// no-transport it falls back to the frozen ~/.botmux authority deny.
- expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('readOnly');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('deny');
// sibling store dirs get no carve-out out of that authority deny either
expect(accessForPath(p.rules, '/Users/u/.botmux/data/session-stores/cli_other/sessions.db').access).toBe('deny');
expect(accessForPath(p.rules, '/opt/botmux/dist/cli.js').access).toBe('readOnly');
diff --git a/test/known-bot-app-ids.test.ts b/test/known-bot-app-ids.test.ts
new file mode 100644
index 0000000000..c7c49c6032
--- /dev/null
+++ b/test/known-bot-app-ids.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { knownBotAppIds } from '../src/services/known-bot-app-ids.js';
+
+function tempDataDir(): string {
+ return mkdtempSync(join(tmpdir(), 'known-bots-'));
+}
+
+describe('knownBotAppIds', () => {
+ it('unions the configured bots, the online descriptors and the own app id', () => {
+ const dataDir = tempDataDir();
+ const botsJsonPath = join(dataDir, 'bots.json');
+ writeFileSync(botsJsonPath, JSON.stringify({ bots: [{ larkAppId: 'cli_conf' }, { appId: 'cli_legacy_key' }, {}] }));
+ mkdirSync(join(dataDir, 'dashboard-daemons'), { recursive: true });
+ writeFileSync(join(dataDir, 'dashboard-daemons', 'cli_online.json'), JSON.stringify({
+ larkAppId: 'cli_online', ipcPort: 9, lastHeartbeat: Date.now(),
+ }));
+ writeFileSync(join(dataDir, 'dashboard-daemons', 'cli_stale.json'), JSON.stringify({
+ larkAppId: 'cli_stale', ipcPort: 9, lastHeartbeat: Date.now() - 10 * 60_000,
+ }));
+
+ const known = knownBotAppIds({ dataDir, botsJsonPath, env: { BOTMUX_LARK_APP_ID: 'cli_self' } });
+ expect([...known].sort()).toEqual(['cli_conf', 'cli_legacy_key', 'cli_online', 'cli_self']);
+ });
+
+ it('degrades to the own app id when bots.json is missing or unreadable', () => {
+ const dataDir = tempDataDir();
+ expect([...knownBotAppIds({ dataDir, botsJsonPath: join(dataDir, 'missing.json'), env: { BOTMUX_LARK_APP_ID: 'cli_self' } })])
+ .toEqual(['cli_self']);
+ const broken = join(dataDir, 'broken.json');
+ writeFileSync(broken, '{not json');
+ expect([...knownBotAppIds({ dataDir, botsJsonPath: broken, env: {} })]).toEqual([]);
+ });
+});
diff --git a/test/mojo-containment.test.ts b/test/mojo-containment.test.ts
index 73a7130409..7878dc6732 100644
--- a/test/mojo-containment.test.ts
+++ b/test/mojo-containment.test.ts
@@ -25,6 +25,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isLinux } from './helpers/synthetic-proc.js';
+import { seedPersistedSessionRows } from './helpers/session-store-disk.js';
import {
MojoContainmentUnavailableError,
@@ -420,34 +421,24 @@ describe('operator revocation is the ONLY unproven exit (P1-3)', () => {
});
it('defaultIsSessionActive is genuinely tri-state (round-4: the implementation itself)', async () => {
- // Every revoke test injects isSessionActive, so a permissive rewrite of
- // the default (`return false`) previously survived the suite. Pin the
- // implementation: active row → true, closed row → false, absent row →
- // false, and — the load-bearing leg — a CORRUPT session file yields
- // `undefined` (evidence unavailable), never a silent "proven inactive".
const { defaultIsSessionActive } = await import('../src/core/mojo-containment-command.js');
- const { config } = await import('../src/config.js');
const dataDir = freshDataDir();
- const prevDataDir = config.session.dataDir;
- config.session.dataDir = dataDir;
- try {
- writeFileSync(join(dataDir, 'sessions-app.json'), JSON.stringify({
- 's-active': { sessionId: 's-active', status: 'active' },
- 's-closed': { sessionId: 's-closed', status: 'closed' },
- }));
- expect(await defaultIsSessionActive('s-active')).toBe(true);
- expect(await defaultIsSessionActive('s-closed')).toBe(false);
- expect(await defaultIsSessionActive('s-absent')).toBe(false);
-
- writeFileSync(join(dataDir, 'sessions-broken.json'), '{ not json');
- // The corrupt file may be the very one hiding the row: unknown.
- expect(await defaultIsSessionActive('s-maybe-hidden')).toBe(undefined);
- // A row still FOUND in a readable file answers definitively even
- // beside a corrupt sibling.
- expect(await defaultIsSessionActive('s-active')).toBe(true);
- } finally {
- config.session.dataDir = prevDataDir;
- }
+ seedPersistedSessionRows(dataDir, 'app', {
+ 's-active': { sessionId: 's-active', status: 'active' },
+ 's-closed': { sessionId: 's-closed', status: 'closed' },
+ });
+ writeFileSync(join(dataDir, 'sessions-app.json'), JSON.stringify({
+ 's-json-only': { sessionId: 's-json-only', status: 'active' },
+ }));
+ expect(await defaultIsSessionActive('s-active', dataDir)).toBe(true);
+ expect(await defaultIsSessionActive('s-closed', dataDir)).toBe(false);
+ expect(await defaultIsSessionActive('s-absent', dataDir)).toBe(false);
+ expect(await defaultIsSessionActive('s-json-only', dataDir)).toBe(false);
+
+ mkdirSync(join(dataDir, 'session-stores', 'broken'), { recursive: true });
+ writeFileSync(join(dataDir, 'session-stores', 'broken', 'sessions.db'), 'not a database');
+ expect(await defaultIsSessionActive('s-maybe-hidden', dataDir)).toBe(undefined);
+ expect(await defaultIsSessionActive('s-active', dataDir)).toBe(true);
});
it('rejects a --handle whose value is another switch instead of eating --yes', async () => {
diff --git a/test/mojo-isolation-inventory-failclosed.test.ts b/test/mojo-isolation-inventory-failclosed.test.ts
index 73f50bc98b..42b03ce605 100644
--- a/test/mojo-isolation-inventory-failclosed.test.ts
+++ b/test/mojo-isolation-inventory-failclosed.test.ts
@@ -27,8 +27,12 @@ vi.mock('../src/services/session-store.js', () => ({
applySessionCommandUnowned: () => ({ outcome: 'missing' }),
readSessionRowUnowned: () => ({ outcome: 'missing' }),
occupancyLeaseIsActive: () => false,
+ hostOccupancyLeaseHeld: () => false,
readOccupancyLease: () => undefined,
- readSessionRowCopiesAcrossStores: () => [],
+ readSessionRowCopiesAcrossStores: () => ({ matches: [], unreadableStores: 0 }),
+ SessionStoreSqliteUnavailableError: class SessionStoreSqliteUnavailableError extends Error {
+ override readonly name = 'SessionStoreSqliteUnavailableError';
+ },
listSessionsStrict: () => {
const err = new Error('session store unreadable');
err.name = 'SessionStoreUnavailableError';
diff --git a/test/resolve-session-by-id.test.ts b/test/resolve-session-by-id.test.ts
new file mode 100644
index 0000000000..990630544b
--- /dev/null
+++ b/test/resolve-session-by-id.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from 'vitest';
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { resolveSessionById } from '../src/cli/resolve-session-by-id.js';
+import { UNMIGRATED_OPERATOR_HINT } from '../src/services/session-store-copy.js';
+import { seedPersistedSessionRows } from './helpers/session-store-disk.js';
+
+describe('resolveSessionById', () => {
+ it('uses a 200 from the owning daemon and rejects an appId mismatch', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ const row = { sessionId: 's1', larkAppId: 'cli_other', chatId: 'oc_1', rootMessageId: 'om_1' };
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: { BOTMUX_LARK_APP_ID: 'cli_self' },
+ findDaemon: () => ({ larkAppId: 'cli_self', ipcPort: 9 }),
+ loadSecret: () => 'secret',
+ fetchIpc: async () => new Response(JSON.stringify({ session: row }), { status: 200 }),
+ });
+ expect(result).toMatchObject({ ok: false, reason: 'app_id_mismatch' });
+ });
+
+ it('treats an answered 404 as authoritative absence', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ mkdirSync(dataDir, { recursive: true });
+ writeFileSync(join(dataDir, 'sessions-cli_self.json'), JSON.stringify({
+ s1: { sessionId: 's1', larkAppId: 'cli_self', status: 'active' },
+ }));
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: { BOTMUX_LARK_APP_ID: 'cli_self' },
+ findDaemon: () => ({ larkAppId: 'cli_self', ipcPort: 9 }),
+ loadSecret: () => 'secret',
+ fetchIpc: async () => new Response(JSON.stringify({ error: 'not_found' }), { status: 404 }),
+ });
+ expect(result).toMatchObject({ ok: false, reason: 'not_found' });
+ });
+
+ it('uses a 200 row when only leftover JSON exists on disk', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ mkdirSync(dataDir, { recursive: true });
+ writeFileSync(join(dataDir, 'sessions-cli_self.json'), JSON.stringify({}));
+ const row = {
+ sessionId: 's1',
+ larkAppId: 'cli_self',
+ chatId: 'oc_1',
+ rootMessageId: 'om_1',
+ status: 'active',
+ };
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: { BOTMUX_LARK_APP_ID: 'cli_self' },
+ findDaemon: () => ({ larkAppId: 'cli_self', ipcPort: 9 }),
+ loadSecret: () => 'secret',
+ fetchIpc: async () => new Response(JSON.stringify({ session: row }), { status: 200 }),
+ });
+ expect(result).toEqual({ ok: true, source: 'daemon', session: row });
+ });
+
+ it('without BOTMUX_LARK_APP_ID, a 404 from a non-owning daemon does not short-circuit the store', async () => {
+ // Bot A is online and does not own s1; bot B (the owner) is offline and
+ // its row lives only in B's SQLite store. The host shell has no appId.
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ const rowB = { sessionId: 's1', larkAppId: 'cli_b', chatId: 'oc_b', rootMessageId: 'om_b', status: 'active' };
+ seedPersistedSessionRows(dataDir, 'cli_b', { s1: rowB });
+ let asked = 0;
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: {},
+ listDaemons: () => [{ larkAppId: 'cli_a', ipcPort: 9 }],
+ loadSecret: () => 'secret',
+ fetchIpc: async () => { asked += 1; return new Response(JSON.stringify({ error: 'not_found' }), { status: 404 }); },
+ });
+ expect(asked).toBe(1);
+ expect(result).toMatchObject({ ok: true, source: 'store', session: { sessionId: 's1', larkAppId: 'cli_b' } });
+ });
+
+ it('without BOTMUX_LARK_APP_ID and no online daemon, reads the store', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ seedPersistedSessionRows(dataDir, 'cli_b', {
+ s1: { sessionId: 's1', larkAppId: 'cli_b', chatId: 'oc_b', rootMessageId: 'om_b', status: 'active' },
+ });
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: {},
+ listDaemons: () => [],
+ loadSecret: () => 'secret',
+ fetchIpc: async () => { throw new Error('must not be called'); },
+ });
+ expect(result).toMatchObject({ ok: true, source: 'store', session: { sessionId: 's1', larkAppId: 'cli_b' } });
+ });
+
+ it('without BOTMUX_LARK_APP_ID, an enumerated daemon that answers 200 wins over the store', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ seedPersistedSessionRows(dataDir, 'cli_b', {
+ s1: { sessionId: 's1', larkAppId: 'cli_b', chatId: 'oc_stale', rootMessageId: 'om_b', status: 'active' },
+ });
+ const live = { sessionId: 's1', larkAppId: 'cli_b', chatId: 'oc_live', rootMessageId: 'om_b', status: 'active' };
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: {},
+ listDaemons: () => [{ larkAppId: 'cli_a', ipcPort: 9 }, { larkAppId: 'cli_b', ipcPort: 10 }],
+ loadSecret: () => 'secret',
+ fetchIpc: async (port: number) => port === 10
+ ? new Response(JSON.stringify({ session: live }), { status: 200 })
+ : new Response(JSON.stringify({ error: 'not_found' }), { status: 404 }),
+ });
+ expect(result).toEqual({ ok: true, source: 'daemon', session: live });
+ });
+
+ it('without BOTMUX_LARK_APP_ID, a leftover JSON of a bot that no longer exists is not_found, not unmigrated', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ writeFileSync(join(dataDir, 'sessions-cli_gone.json'), JSON.stringify({
+ s1: { sessionId: 's1', larkAppId: 'cli_gone', status: 'active' },
+ }));
+ const base = {
+ dataDir,
+ env: {},
+ listDaemons: () => [],
+ loadSecret: () => 'secret',
+ fetchIpc: async () => { throw new Error('must not be called'); },
+ };
+ expect(await resolveSessionById('s1', { ...base, knownAppIds: new Set(['cli_a']) }))
+ .toMatchObject({ ok: false, reason: 'not_found' });
+ // Still configured → the file really is a pending migration.
+ expect(await resolveSessionById('s1', { ...base, knownAppIds: new Set(['cli_gone']) }))
+ .toMatchObject({ ok: false, reason: 'unmigrated' });
+ });
+
+ it('falls back to unmigrated when the daemon does not answer', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'resolve-session-'));
+ mkdirSync(dataDir, { recursive: true });
+ writeFileSync(join(dataDir, 'sessions-cli_self.json'), JSON.stringify({
+ s1: { sessionId: 's1', larkAppId: 'cli_self', status: 'active' },
+ }));
+ const result = await resolveSessionById('s1', {
+ dataDir,
+ env: { BOTMUX_LARK_APP_ID: 'cli_self' },
+ findDaemon: () => ({ larkAppId: 'cli_self', ipcPort: 9 }),
+ loadSecret: () => 'secret',
+ fetchIpc: async () => { throw new Error('connect ECONNREFUSED'); },
+ });
+ expect(result).toMatchObject({ ok: false, reason: 'unmigrated', message: UNMIGRATED_OPERATOR_HINT });
+ });
+});
diff --git a/test/session-delete-cli.test.ts b/test/session-delete-cli.test.ts
index 7c629a38b5..448108670b 100644
--- a/test/session-delete-cli.test.ts
+++ b/test/session-delete-cli.test.ts
@@ -125,7 +125,7 @@ function writeReadIsolatedCapability(dataDir: string, sessionId: string): void {
chmodSync(path, 0o600);
}
-function runDelete(
+function runCli(
dataDir: string,
args: string[],
envOverrides: Record = {},
@@ -146,7 +146,7 @@ function runDelete(
}
const child = spawnTsScript(
CLI_PATH,
- ['delete', ...args],
+ args,
{ env, stdio: ['ignore', 'pipe', 'pipe'] },
) as ChildProcessWithoutNullStreams;
let stdout = '';
@@ -160,6 +160,14 @@ function runDelete(
});
}
+function runDelete(
+ dataDir: string,
+ args: string[],
+ envOverrides: Record = {},
+): Promise<{ status: number | null; stdout: string; stderr: string }> {
+ return runCli(dataDir, ['delete', ...args], envOverrides);
+}
+
function readRequestBody(req: IncomingMessage): Promise> {
return new Promise((resolve) => {
let raw = '';
@@ -366,11 +374,11 @@ describe('botmux delete — daemon-first close', () => {
});
expect(result.status).toBe(1);
- expect(result.stderr).toContain('owning_daemon_became_available');
+ expect(result.stderr).toContain('daemon 在线');
expect(readSessions(dataDir)[session.sessionId].status).toBe('active');
});
- it('closes offline once the lease has expired and no heartbeat is fresh', async () => {
+ it('closes offline once the lease has expired and no daemon is discoverable', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'botmux-delete-data-'));
tempDirs.push(dataDir);
const session = makeSession('sess-delete-lease-lapsed');
@@ -610,11 +618,7 @@ describe('botmux delete — daemon-first close', () => {
}
});
- it('closes a session whose owning daemon has not imported the store yet', async () => {
- // 升级窗口:npm 换了 dist、重指了 launcher,而拥有这些行的 daemon 还在跑
- // 迁移前的版本、仍然读写 JSON。离线关闭必须照常落到那份 JSON 上——读不到
- // 会让 CLI 报「没有活跃会话」,等于告诉用户会话没了;而在这里建 .db 会在
- // 那台 daemon 背后把两种表示分叉,还会关掉它的一次性导入门。
+ it('refuses delete when the store is still unmigrated leftover JSON', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'botmux-delete-unimported-'));
tempDirs.push(dataDir);
const session = makeSession('sess-delete-unimported');
@@ -629,9 +633,71 @@ describe('botmux delete — daemon-first close', () => {
BOTMUX_DAEMON_IPC_PORT: undefined,
});
- expect(result.status).toBe(0);
- expect(result.stdout).not.toContain('没有活跃会话');
- expect(JSON.parse(readFileSync(jsonFp, 'utf-8'))[session.sessionId].status).toBe('closed');
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain('尚未迁移到 SQLite');
+ expect(result.stderr + result.stdout).not.toContain('没有活跃会话');
+ expect(JSON.parse(readFileSync(jsonFp, 'utf-8'))[session.sessionId].status).toBe('active');
expect(existsSync(sessionStorePath(dataDir, APP_ID))).toBe(false);
});
+
+ it('refuses delete against a fresh pre-capability daemon and leaves the row', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'botmux-delete-legacy-daemon-'));
+ tempDirs.push(dataDir);
+ const session = makeSession('sess-delete-legacy-daemon');
+ writeSessions(dataDir, [session]);
+ writeDaemonDescriptor(dataDir, 1);
+
+ const result = await runDelete(dataDir, [session.sessionId], {
+ BOTMUX_SESSION_ID: undefined,
+ BOTMUX_LARK_APP_ID: APP_ID,
+ BOTMUX_SEND_RELAY: undefined,
+ BOTMUX_DAEMON_IPC_PORT: undefined,
+ });
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain('后台 daemon 是升级前的旧进程');
+ expect(result.stderr).toContain('botmux restart');
+ expect(readSessions(dataDir)[session.sessionId].status).toBe('active');
+ });
+
+ it('does not mention botmux restart in a session-scoped delete against a legacy daemon', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'botmux-delete-scoped-legacy-'));
+ tempDirs.push(dataDir);
+ const session = makeSession('sess-delete-scoped-legacy');
+ writeSessions(dataDir, [session]);
+ writeDaemonDescriptor(dataDir, 1);
+
+ const result = await runDelete(dataDir, [session.sessionId], {
+ BOTMUX_SESSION_ID: session.sessionId,
+ BOTMUX_LARK_APP_ID: APP_ID,
+ BOTMUX_SEND_RELAY: undefined,
+ BOTMUX_DAEMON_IPC_PORT: undefined,
+ });
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain('本次未做任何修改');
+ expect(result.stderr).not.toContain('botmux restart');
+ expect(result.stderr).not.toMatch(/\bpid\b/i);
+ expect(result.stderr).not.toMatch(/\bport\b/i);
+ expect(result.stderr).not.toMatch(/v?\d+\.\d+\.\d+/);
+ expect(readSessions(dataDir)[session.sessionId].status).toBe('active');
+ });
+
+ it('list prints unmigrated instead of 没有活跃会话 when only leftover JSON exists', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'botmux-list-unmigrated-'));
+ tempDirs.push(dataDir);
+ const session = makeSession('sess-list-unmigrated');
+ mkdirSync(dataDir, { recursive: true });
+ writeFileSync(join(dataDir, `sessions-${APP_ID}.json`), JSON.stringify({ [session.sessionId]: session }));
+
+ const result = await runCli(dataDir, ['list', '--plain'], {
+ BOTMUX_SESSION_ID: undefined,
+ BOTMUX_LARK_APP_ID: APP_ID,
+ BOTMUX_SEND_RELAY: undefined,
+ BOTMUX_DAEMON_IPC_PORT: undefined,
+ });
+
+ expect(result.stderr).toContain('尚未迁移到 SQLite');
+ expect(result.stdout + result.stderr).not.toContain('没有活跃会话');
+ });
});
diff --git a/test/session-occupancy.test.ts b/test/session-occupancy.test.ts
index cac47a5a06..dd3dff8355 100644
--- a/test/session-occupancy.test.ts
+++ b/test/session-occupancy.test.ts
@@ -1,10 +1,7 @@
/**
* Stage 1 occupancy:库内租约与心跳文件拆开后的所有权回归。
*
- * 核心洞:「心跳陈旧 + 库内租约有效」在旧实现会放行离线写(abortIf 只读
- * dashboard-daemons)。现探测与 persist 必须在同一 BEGIN IMMEDIATE 里完成。
- * 有效租约一票否决;没有有效租约(缺行 / 过期 / 不可读)时心跳仍参与判定——
- * 这是升级窗口(只写会话行、不写 occupancy 的 daemon,含回滚后的旧构建)。
+ * 有效租约一票否决;没有有效租约时新鲜 descriptor 仍拒绝(只拒绝、带原因)。
*
* Run: bunx vitest run test/session-occupancy.test.ts
*/
@@ -35,6 +32,7 @@ import {
listSessions,
getSession,
applySessionCommandUnowned,
+ readSessionRowUnowned,
readOccupancyLease,
claimOccupancyLease,
releaseOccupancyLease,
@@ -59,13 +57,18 @@ function row(sessionId: string, extra: Record = {}): Record = {},
+): void {
const dir = join(tempDir, 'dashboard-daemons');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${appId}.json`), JSON.stringify({
larkAppId: appId,
ipcPort: 12345,
lastHeartbeat,
+ ...extra,
}));
}
@@ -97,7 +100,7 @@ afterEach(() => {
});
describe('occupancy vs heartbeat window', () => {
- it('stale heartbeat + live lease aborts the offline write (the Stage 1 hole)', () => {
+ it('stale heartbeat + live lease aborts the offline write', () => {
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
seedOccupancyLease(tempDir, 'appA', {
ownerPid: 4242,
@@ -106,29 +109,42 @@ describe('occupancy vs heartbeat window', () => {
});
writeDaemonHeartbeat('appA', Date.now() - 120_000);
- expect(closeS1Offline()).toEqual({ outcome: 'owned' });
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'lease' });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(true);
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe('lease');
});
- it('expired lease + fresh heartbeat still aborts (upgrade window: a daemon that never wrote occupancy, or a rollback behind a stale row)', () => {
+ it('expired lease + fresh heartbeat without capability is legacy_daemon', () => {
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
seedOccupancyLease(tempDir, 'appA', {
- ownerPid: 4242,
+ ownerPid: deadPid(),
bootId: 'boot-crashed-newer-build',
leaseUntil: Date.now() - 1,
});
writeDaemonHeartbeat('appA', Date.now());
- expect(closeS1Offline()).toEqual({ outcome: 'owned' });
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'legacy_daemon' });
+ expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe('legacy_daemon');
+ });
+
+ it('expired lease + live owner pid still blocks the host (fail-closed)', () => {
+ seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
+ seedOccupancyLease(tempDir, 'appA', {
+ ownerPid: process.pid,
+ bootId: 'boot-wedged',
+ leaseUntil: Date.now() - 1,
+ });
+
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'lease' });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(true);
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe('lease');
});
it('expired lease + stale heartbeat allows the offline write', () => {
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
seedOccupancyLease(tempDir, 'appA', {
- ownerPid: 4242,
+ ownerPid: deadPid(),
bootId: 'boot-dead',
leaseUntil: Date.now() - 1,
});
@@ -136,33 +152,60 @@ describe('occupancy vs heartbeat window', () => {
expect(closeS1Offline()).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('closed');
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(false);
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBeUndefined();
});
- it('missing lease + fresh heartbeat aborts (upgrade-window fallback)', () => {
+ it('missing lease + fresh heartbeat without capability is legacy_daemon', () => {
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
writeDaemonHeartbeat('appA', Date.now());
- expect(closeS1Offline()).toEqual({ outcome: 'owned' });
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'legacy_daemon' });
+ expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe('legacy_daemon');
+ });
+
+ it('missing lease + fresh heartbeat with sessionStoreProtocol is daemon_without_lease', () => {
+ seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
+ writeDaemonHeartbeat('appA', Date.now(), { sessionStoreProtocol: 'occupancy-v1' });
+
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'daemon_without_lease' });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(true);
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe('daemon_without_lease');
});
it('missing lease + no heartbeat allows the offline write', () => {
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
expect(closeS1Offline()).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(false);
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBeUndefined();
});
- it('isOccupancyHeld never throws on an unreadable store — the heartbeat decides', () => {
+ it('isOccupancyHeld never throws on an unreadable store', () => {
const path = sessionStorePath(tempDir, 'appA');
mkdirSync(join(tempDir, 'session-stores', 'appA'), { recursive: true });
writeFileSync(path, 'definitely not a sqlite database\n'.repeat(64));
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(false);
- writeDaemonHeartbeat('appA', Date.now());
- expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBe(true);
+ expect(() => isOccupancyHeld('appA', { dataDir: tempDir })).not.toThrow();
+ expect(isOccupancyHeld('appA', { dataDir: tempDir })).toBeUndefined();
+ });
+
+ it('read and apply both yield owned when a live lease exists', () => {
+ seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
+ seedOccupancyLease(tempDir, 'appA', {
+ ownerPid: 4242,
+ bootId: 'boot-live',
+ leaseUntil: Date.now() + 60_000,
+ });
+ expect(applySessionCommandUnowned(
+ { sessionId: 's1', larkAppId: 'appA' },
+ { type: 'close' },
+ { dataDir: tempDir },
+ )).toEqual({ outcome: 'owned', heldBy: 'lease' });
+ expect(readSessionRowUnowned(
+ { sessionId: 's1', larkAppId: 'appA' },
+ { dataDir: tempDir },
+ )).toEqual({ outcome: 'owned', heldBy: 'lease' });
+ expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
});
});
@@ -213,7 +256,7 @@ describe('load() claims occupancy in the same IMMEDIATE transaction', () => {
expect(readOccupancyLeaseFromDisk(tempDir, 'appA')).toMatchObject(foreign);
// Offline writers keep yielding to the predecessor until it lets go.
- expect(closeS1Offline()).toEqual({ outcome: 'owned' });
+ expect(closeS1Offline()).toEqual({ outcome: 'owned', heldBy: 'lease' });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
});
@@ -324,11 +367,15 @@ describe('claim / release occupancy', () => {
});
it('an expired lease that is then re-claimed blocks the next offline write', () => {
+ // The seeded owner must be a pid that is provably dead: the host rule is
+ // "unexpired OR owner alive", so a live pid (99 can be a kernel thread on
+ // the host pid namespace) would keep the expired lease held.
+ const ownerPid = deadPid();
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
- init('appA', { occupancy: { bootId: 'boot-owner', pid: 99 } });
+ init('appA', { occupancy: { bootId: 'boot-owner', pid: ownerPid } });
listSessions();
seedOccupancyLease(tempDir, 'appA', {
- ownerPid: 99,
+ ownerPid,
bootId: 'boot-owner',
leaseUntil: Date.now() - 1,
});
@@ -340,41 +387,16 @@ describe('claim / release occupancy', () => {
)).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
seedPersistedSessionRows(tempDir, 'appA', { s1: row('s1', { larkAppId: 'appA' }) });
- expect(claimOccupancyLease({ bootId: 'boot-owner', pid: 99 })).toBe('held');
+ expect(claimOccupancyLease({ bootId: 'boot-owner', pid: ownerPid })).toBe('held');
expect(applySessionCommandUnowned(
{ sessionId: 's1', larkAppId: 'appA' },
{ type: 'close' },
{ dataDir: tempDir },
- )).toEqual({ outcome: 'owned' });
+ )).toEqual({ outcome: 'owned', heldBy: 'lease' });
expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
});
});
-describe('JSON upgrade-window path still uses abortIf', () => {
- it('does not create a .db and still honours the heartbeat probe', () => {
- mkdirSync(tempDir, { recursive: true });
- writeFileSync(join(tempDir, 'sessions-appA.json'), JSON.stringify({
- s1: row('s1', { larkAppId: 'appA' }),
- }));
-
- const aborted = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => true },
- );
- expect(aborted).toEqual({ outcome: 'owned' });
-
- const published = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => false },
- );
- expect(published).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
- expect(JSON.parse(readFileSync(join(tempDir, 'sessions-appA.json'), 'utf-8')).s1.status).toBe('closed');
- expect(existsSync(sessionStorePath(tempDir, 'appA'))).toBe(false);
- });
-});
-
describe('host close post-commit cleanup', () => {
it('deletes the materialized dashboard image directory after an offline close', () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2ZVQAAAAASUVORK5CYII=';
diff --git a/test/session-store-copy.test.ts b/test/session-store-copy.test.ts
new file mode 100644
index 0000000000..bddf09ad4f
--- /dev/null
+++ b/test/session-store-copy.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import {
+ formatStoreHoldMessage,
+ formatUnmigratedMessage,
+ SESSION_STORE_WRITE_BLOCKED,
+ UNMIGRATED_OPERATOR_HINT,
+} from '../src/services/session-store-copy.js';
+
+describe('session-store-copy', () => {
+ it('gives operators a restart instruction and session processes only a status', () => {
+ expect(formatUnmigratedMessage()).toBe(UNMIGRATED_OPERATOR_HINT);
+ expect(formatUnmigratedMessage({ sessionScoped: true })).not.toContain('botmux restart');
+ expect(formatStoreHoldMessage('legacy_daemon')).toContain('botmux restart');
+ expect(formatStoreHoldMessage('legacy_daemon', { sessionScoped: true })).toBe(SESSION_STORE_WRITE_BLOCKED);
+ expect(formatStoreHoldMessage('lease', { sessionScoped: true })).not.toMatch(/pid|port|v\d+\.\d+/);
+ });
+});
diff --git a/test/session-store-sqlite.test.ts b/test/session-store-sqlite.test.ts
index f33ee439d6..d3d7fb0bf1 100644
--- a/test/session-store-sqlite.test.ts
+++ b/test/session-store-sqlite.test.ts
@@ -199,20 +199,14 @@ describe('first-start JSON import', () => {
expect(listSessionsStrict().map(s => s.sessionId).sort()).toEqual(['s1', 's2']);
});
- it('a non-owning process (worker under an old daemon) reads the JSON and never bootstraps the .db', () => {
+ it('a non-owning process does not bootstrap a .db and treats leftover JSON as unmigrated', () => {
seedJson('sessions-appA.json', { s1: row('s1') });
- // worker(owner:false)在旧 daemon 还没导入时启动:照常读得到会话(那台
- // daemon 还在写这份 JSON),但不许建库——建了就等于在它背后把两种表示
- // 分叉,还会把它的一次性导入门关掉。
init('appA', { owner: false });
- expect(getSession('s1')?.title).toBe('s1');
- expect(listSessionsStrict()).toHaveLength(1);
+ expect(() => listSessionsStrict()).toThrow(/尚未迁移到 SQLite|unmigrated|unavailable/i);
expect(existsSync(join(tempDir, 'session-stores', 'appA', 'sessions.db'))).toBe(false);
- // 只读:不得把 scope 修复或 legacy 迁移写回 JSON(那是拥有者 daemon 的活)。
expect(JSON.parse(readFileSync(join(tempDir, 'sessions-appA.json'), 'utf-8')).s1.title).toBe('s1');
- // daemon(owner)随后启动才导入
init('appA');
expect(getSession('s1')?.title).toBe('s1');
expect(existsSync(join(tempDir, 'session-stores', 'appA', 'sessions.db'))).toBe(true);
@@ -240,17 +234,14 @@ describe('the frozen import source is not a store', () => {
expect(readSessionRowFromDisk('s1', 'appA', tempDir)?.title).toBe('live db copy');
expect(loadAllSessionsSnapshot({ dataDir: tempDir }).get('s1')?.title).toBe('live db copy');
// 身份扫描:同一 store 的冻结 JSON 不算第二份拷贝(exactly-once 证明不被打破)
- expect(readSessionRowCopiesAcrossStores('s1', tempDir)).toHaveLength(1);
+ expect(readSessionRowCopiesAcrossStores('s1', tempDir).matches).toHaveLength(1);
// 冻结 JSON 独有的行不可见——读者一律以 .db 为准
expect(loadAllSessionsSnapshot({ dataDir: tempDir }).has('ghost')).toBe(false);
expect(countActiveSessionsOnDisk(tempDir)).toBe(1);
});
- it('an un-imported peer store still composes with imported ones', () => {
- // 混合升级窗口:appOld 的 daemon 还在跑迁移前的版本(只有 JSON),appNew
- // 已经切到 SQLite。跨 bot 发现(继承 workingDir、活跃数统计、adopt 去重)
- // 必须同时看见两者——把没重启过的 peer 读成「不存在」会让继承和去重失效。
- seedJson('sessions-appOld.json', { o1: row('o1', { larkAppId: 'appOld' }) });
+ it('an imported peer store still composes with other imported ones', () => {
+ seedPersistedSessionRows(tempDir, 'appOld', { o1: row('o1', { larkAppId: 'appOld' }) });
init('appNew');
const n1 = createSession('oc_chat', 'om_shared_root', 'new bot session');
n1.larkAppId = 'appNew';
@@ -278,48 +269,6 @@ describe('the frozen import source is not a store', () => {
expect(getSession(s.sessionId)?.title).toBe('fresh probe'); // 缓存语义不变
});
- it('offline mutation targets the .db when it exists and leaves the frozen JSON untouched', () => {
- const jsonFp = seedJson('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
- init('appA');
- expect(getSession('s1')?.status).toBe('active'); // 首次访问触发导入 → .db
- const jsonAfterImport = readFileSync(jsonFp, 'utf-8');
-
- const published = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir },
- );
- expect(published).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
- expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('closed');
- expect(readFileSync(jsonFp, 'utf-8')).toBe(jsonAfterImport);
- expect(JSON.parse(jsonAfterImport).s1.status).toBe('active');
- });
-
- it('offline mutation on the .db keeps the abortIf entry + pre-publication probes', () => {
- seedJson('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
- init('appA');
- listSessions(); // 触发导入 → .db
- const before = readPersistedSessionRows(tempDir, 'appA');
-
- let probes = 0;
- const aborted = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => ++probes > 1 },
- );
- expect(aborted).toEqual({ outcome: 'owned' });
- expect(probes).toBe(2);
- expect(readPersistedSessionRows(tempDir, 'appA')).toEqual(before);
-
- const abortedAtEntry = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => true },
- );
- expect(abortedAtEntry).toEqual({ outcome: 'owned' });
- expect(readPersistedSessionRows(tempDir, 'appA')).toEqual(before);
- });
-
it('offline mutation hands mutate the FRESH .db row, never the caller snapshot', () => {
seedJson('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
init('appA');
@@ -525,23 +474,11 @@ describe('SQLite capability gate', () => {
expect(() => readSessionRowFromDisk('s1', 'appA', tempDir)).toThrow(SessionStoreSqliteUnavailableError);
expect(() => loadAllSessionsSnapshot({ dataDir: tempDir })).toThrow(SessionStoreSqliteUnavailableError);
expect(() => readSessionRowCopiesAcrossStores('s1', tempDir)).toThrow(SessionStoreSqliteUnavailableError);
- expect(() => applySessionCommandUnowned(
+ expect(applySessionCommandUnowned(
{ sessionId: 's1', larkAppId: 'appA' },
{ type: 'close' },
{ dataDir: tempDir },
- )).toThrow(SessionStoreSqliteUnavailableError);
- });
-
- it('an un-imported peer store still resolves for the identity scan', () => {
- // 身份扫描是 fail-closed 的「恰好一次」判定。窗口期把某个 store 读成
- // 「不存在」,会让本来能唯一命中的行变成 0 命中,命令报「未找到当前进程
- // 所属 session」,把人引去查进程标记。
- seedJson('sessions-appOld.json', { o1: row('o1', { larkAppId: 'appOld' }) });
- seedPersistedSessionRows(tempDir, 'appA', { a1: row('a1', { larkAppId: 'appA' }) });
-
- expect(readSessionRowCopiesAcrossStores('a1', tempDir)).toHaveLength(1);
- expect(readSessionRowCopiesAcrossStores('o1', tempDir)).toHaveLength(1);
- expect(readSessionRowCopiesAcrossStores('missing', tempDir)).toHaveLength(0);
+ )).toEqual({ outcome: 'owned', heldBy: 'store_unreadable' });
});
it('a corrupt .db is a skippable store, not a missing-engine error', () => {
@@ -552,7 +489,8 @@ describe('SQLite capability gate', () => {
expect(() => assertSqliteSupported()).not.toThrow();
const copies = readSessionRowCopiesAcrossStores('s1', tempDir);
- expect(copies).toHaveLength(1);
- expect(copies[0]?.title).toBe('other-bot');
+ expect(copies.matches).toHaveLength(1);
+ expect(copies.unreadableStores).toBe(1);
+ expect(copies.matches[0]?.title).toBe('other-bot');
});
});
diff --git a/test/session-store.test.ts b/test/session-store.test.ts
index 3704f78fb2..b9153f7cbb 100644
--- a/test/session-store.test.ts
+++ b/test/session-store.test.ts
@@ -13,7 +13,7 @@ import { tmpdir } from 'os';
// ─── Mocks ────────────────────────────────────────────────────────────────
-const fsControl = vi.hoisted(() => ({ failSessionWrite: false, failReaddir: false }));
+const fsControl = vi.hoisted(() => ({ failReaddir: false }));
const costCalculatorMock = vi.hoisted(() => ({
getSessionTokenUsage: vi.fn(() => null),
}));
@@ -21,12 +21,6 @@ vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- writeFileSync: (...args: Parameters) => {
- if (fsControl.failSessionWrite && String(args[0]).includes('sessions.json.')) {
- throw new Error('simulated session repair write failure');
- }
- return actual.writeFileSync(...args);
- },
readdirSync: (...args: Parameters) => {
// Simulates the CLI file sandbox: per-bot files readable, data dir
// enumeration denied (EPERM-like failure).
@@ -93,7 +87,6 @@ import {
readSessionRowCopiesAcrossStores,
} from '../src/services/session-store.js';
import { seedPersistedSessionRows, readPersistedSessionRows, sessionStorePath } from './helpers/session-store-disk.js';
-import { withFileLockSync } from '../src/utils/file-lock.js';
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -135,7 +128,6 @@ function readPersistedRows(dir: string, appId?: string): Record {
beforeEach(() => {
tempDir = makeTempDir();
- fsControl.failSessionWrite = false;
costCalculatorMock.getSessionTokenUsage.mockReset();
costCalculatorMock.getSessionTokenUsage.mockReturnValue(null);
__testOnly_setBeforeRowPersist(undefined);
@@ -270,38 +262,6 @@ describe('init()', () => {
expect(repairMissingChatScope({ sessionId: 'malformed' })).toBe(false);
});
- it('keeps loaded sessions available when persisting a scope repair fails', () => {
- mkdirSync(tempDir, { recursive: true });
- const fp = join(tempDir, 'sessions.json');
- writeFileSync(fp, JSON.stringify({
- broken: {
- sessionId: 'broken',
- chatId: 'oc_chat',
- rootMessageId: 'oc_chat',
- title: 'Broken repo switch',
- status: 'active',
- createdAt: '2026-07-18T00:00:00.000Z',
- },
- healthy: {
- sessionId: 'healthy',
- chatId: 'oc_chat',
- rootMessageId: 'om_thread',
- scope: 'thread',
- title: 'Healthy thread',
- status: 'active',
- createdAt: '2026-07-18T00:00:00.000Z',
- },
- }));
-
- fsControl.failSessionWrite = true;
- init();
-
- expect(getSession('broken')?.scope).toBe('chat');
- expect(getSession('healthy')?.title).toBe('Healthy thread');
- expect(listSessions()).toHaveLength(2);
- expect(JSON.parse(readFileSync(fp, 'utf-8')).broken.scope).toBeUndefined();
- });
-
it('should reset state when called again', () => {
createSession('chat1', 'root1', 'Session A');
expect(listSessions()).toHaveLength(1);
@@ -533,6 +493,7 @@ describe('write health gate', () => {
SessionStoreUnavailableError,
);
expect(readFileSync(legacyFp, 'utf-8')).toBe('{broken-legacy');
+ expect(existsSync(sessionStorePath(tempDir, 'app-A'))).toBe(false);
expect(existsSync(botFp)).toBe(false);
});
@@ -1312,7 +1273,7 @@ describe('Multi-bot isolation', () => {
expect(listSessions()).toHaveLength(2);
});
- it('should use legacy sessions.json when no appId is set', () => {
+ it('should use the flat sessions.db when no appId is set', () => {
init();
createSession('c1', 'r1', 'Legacy');
expect(persistedStoreExists(tempDir)).toBe(true);
@@ -1438,13 +1399,6 @@ describe('Edge cases', () => {
init(); // re-init loads from disk
expect(listSessions()).toHaveLength(2);
});
-
- it('should handle atomic writes (tmp file rename)', () => {
- const session = createSession('c1', 'r1', 'Atomic');
- // The .tmp file should not persist after save
- const tmpFp = join(tempDir, 'sessions.json.tmp');
- expect(existsSync(tmpFp)).toBe(false);
- });
});
// ─── legacy field sanitization ───────────────────────────────────────────────
@@ -1571,21 +1525,36 @@ describe('loadAllSessionsSnapshot()', () => {
mkdirSync(tempDir, { recursive: true });
expect(loadAllSessionsSnapshot({ dataDir: tempDir }).size).toBe(0);
// A read-write SQLite open would have CREATED this file, and its mere
- // existence disables the owning daemon's one-shot JSON import.
+ // existence would hide the unmigrated (JSON-only) probe.
expect(existsSync(sessionStorePath(tempDir))).toBe(false);
});
- it('still reads a store whose owning daemon has not imported it yet', () => {
- // Upgrade window: npm already replaced dist and repointed the launcher, but
- // the daemon that owns these rows still runs the pre-SQLite build and keeps
- // writing the JSON. Every live session's `botmux send` resolves itself
- // through here — going db-only would leave the agent unable to reply until
- // someone restarts the daemon, which nothing forces them to do.
+ it('marks a leftover JSON store as unmigrated and does not read its rows', () => {
seedFile('sessions-appB.json', { b1: row('b1') });
seedStore('appA', { a1: row('a1') });
const snapshot = loadAllSessionsSnapshot({ dataDir: tempDir });
- expect([...snapshot.keys()].sort()).toEqual(['a1', 'b1']);
- expect(snapshot.get('b1')?.larkAppId).toBe('appB');
+ expect([...snapshot.keys()]).toEqual(['a1']);
+ expect(snapshot.unmigratedAppIds).toEqual(['appB']);
+ expect(applySessionCommandUnowned(
+ { sessionId: 'b1', larkAppId: 'appB' },
+ { type: 'close' },
+ { dataDir: tempDir },
+ )).toEqual({ outcome: 'unmigrated' });
+ });
+
+ it('reports a leftover JSON as unmigrated only for bots that still exist', () => {
+ // appGone was removed from bots.json long ago; nothing will ever import its
+ // file, so it must not keep telling the operator to restart the daemon.
+ seedFile('sessions-appB.json', { b1: row('b1') });
+ seedFile('sessions-appGone.json', { g1: row('g1') });
+ seedStore('appA', { a1: row('a1') });
+
+ expect(loadAllSessionsSnapshot({ dataDir: tempDir }).unmigratedAppIds).toEqual(['appB', 'appGone']);
+ expect(loadAllSessionsSnapshot({ dataDir: tempDir, knownAppIds: new Set(['appA', 'appB']) }).unmigratedAppIds)
+ .toEqual(['appB']);
+ // The bot this process runs under is always known, even if not configured.
+ expect(loadAllSessionsSnapshot({ dataDir: tempDir, knownAppIds: new Set(), fallbackAppId: 'appGone' }).unmigratedAppIds)
+ .toEqual(['appGone']);
});
});
@@ -1613,9 +1582,10 @@ describe('readSessionRowCopiesAcrossStores()', () => {
seedStore('appA', { s1: row('s1', { title: 'per-bot' }) });
seedStore('appB', { other: row('other') });
const copies = readSessionRowCopiesAcrossStores('s1', tempDir);
- expect(copies.map(c => c.title).sort()).toEqual(['legacy', 'per-bot']);
- expect(readSessionRowCopiesAcrossStores('other', tempDir)).toHaveLength(1);
- expect(readSessionRowCopiesAcrossStores('missing', tempDir)).toHaveLength(0);
+ expect(copies.matches.map(c => c.title).sort()).toEqual(['legacy', 'per-bot']);
+ expect(copies.unreadableStores).toBe(0);
+ expect(readSessionRowCopiesAcrossStores('other', tempDir).matches).toHaveLength(1);
+ expect(readSessionRowCopiesAcrossStores('missing', tempDir).matches).toHaveLength(0);
});
it('skips corrupt stores and key-mismatched rows without failing the scan', () => {
@@ -1624,18 +1594,8 @@ describe('readSessionRowCopiesAcrossStores()', () => {
seedStore('appB', { s1: row('someOtherId') }); // key ≠ row.sessionId
seedStore(undefined, { s1: row('s1') });
const copies = readSessionRowCopiesAcrossStores('s1', tempDir);
- expect(copies).toHaveLength(1);
- });
-
- it('a frozen pre-SQLite JSON is not a second copy of an imported store', () => {
- // The identity scan authorises only when a row resolves EXACTLY once. The
- // frozen import source must not read as a second store, or every migrated
- // session would be refused as ambiguous.
- seedFile('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
- init('appA');
- listSessions(); // import → .db, JSON frozen in place
- init();
- expect(readSessionRowCopiesAcrossStores('s1', tempDir)).toHaveLength(1);
+ expect(copies.matches).toHaveLength(1);
+ expect(copies.unreadableStores).toBe(1);
});
it('throws when the data dir itself cannot be listed (fail-closed identity scan)', () => {
@@ -1709,36 +1669,6 @@ describe('applySessionCommandUnowned() / readSessionRowUnowned()', () => {
)).toEqual({ outcome: 'missing' });
});
- it('yields owned untouched when abortIf trips at entry — for the read as well as the apply', () => {
- seedStore('appA', { s1: row('s1') });
- expect(applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => true },
- )).toEqual({ outcome: 'owned' });
- expect(readSessionRowUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { dataDir: tempDir, abortIf: () => true },
- )).toEqual({ outcome: 'owned' });
- expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
- });
-
- it('re-checks abortIf immediately before publication and leaves the row untouched', () => {
- // A daemon that appears during the read/decision phase becomes
- // authoritative — the second probe must catch it. SQLite's own locking
- // orders writers but cannot see a daemon holding a stale in-memory cache.
- seedStore('appA', { s1: row('s1') });
- let probes = 0;
- const result = applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir, abortIf: () => ++probes > 1 },
- );
- expect(result).toEqual({ outcome: 'owned' });
- expect(probes).toBe(2);
- expect(readPersistedSessionRows(tempDir, 'appA').s1.status).toBe('active');
- });
-
it('targets the legacy store when the row carries no larkAppId', () => {
seedStore(undefined, { s1: row('s1') });
const published = applySessionCommandUnowned(
@@ -1751,9 +1681,8 @@ describe('applySessionCommandUnowned() / readSessionRowUnowned()', () => {
});
it('never creates the store — an empty one would disable the daemon import gate', () => {
- // A read-write SQLite open CREATES the file. If an offline write planted an
- // empty store here, the owning daemon's `existsSync(db)` gate would skip
- // the one-shot JSON import and silently discard every pre-SQLite row.
+ // A read-write SQLite open CREATES the file. An empty store here would
+ // hide the unmigrated probe for leftover JSON.
mkdirSync(join(tempDir, 'session-stores', 'appA'), { recursive: true });
expect(applySessionCommandUnowned(
{ sessionId: 's1', larkAppId: 'appA' },
@@ -1763,33 +1692,14 @@ describe('applySessionCommandUnowned() / readSessionRowUnowned()', () => {
expect(existsSync(sessionStorePath(tempDir, 'appA'))).toBe(false);
});
- it('writes the JSON store while its owning daemon has not imported it yet', () => {
- // Upgrade window: the pre-SQLite daemon still owns these rows and reads the
- // JSON, so an offline close has to land there. Creating a .db here would
- // fork the two representations behind that daemon's back — and the empty
- // store would also disable its one-shot import gate.
+ it('returns unmigrated for a leftover JSON store and never creates a .db', () => {
seedFile('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
- const published = applySessionCommandUnowned(
+ expect(applySessionCommandUnowned(
{ sessionId: 's1', larkAppId: 'appA' },
{ type: 'close' },
{ dataDir: tempDir },
- );
- expect(published).toMatchObject({ outcome: 'applied', row: { status: 'closed' } });
- expect(JSON.parse(readFileSync(join(tempDir, 'sessions-appA.json'), 'utf-8')).s1.status).toBe('closed');
+ )).toEqual({ outcome: 'unmigrated' });
+ expect(JSON.parse(readFileSync(join(tempDir, 'sessions-appA.json'), 'utf-8')).s1.status).toBe('active');
expect(existsSync(sessionStorePath(tempDir, 'appA'))).toBe(false);
});
-
- it('reports contended (not missing) when the JSON store file lock is held past its wait', () => {
- seedFile('sessions-appA.json', { s1: row('s1', { larkAppId: 'appA' }) });
- const held = withFileLockSync(
- join(tempDir, 'sessions-appA.json'),
- () => applySessionCommandUnowned(
- { sessionId: 's1', larkAppId: 'appA' },
- { type: 'close' },
- { dataDir: tempDir },
- ),
- );
- expect(held).toEqual({ outcome: 'contended' });
- expect(JSON.parse(readFileSync(join(tempDir, 'sessions-appA.json'), 'utf-8')).s1.status).toBe('active');
- }, 15_000);
});
diff --git a/test/shutdown-supervisor-contract.test.ts b/test/shutdown-supervisor-contract.test.ts
index 6bee7b2b39..50f5593648 100644
--- a/test/shutdown-supervisor-contract.test.ts
+++ b/test/shutdown-supervisor-contract.test.ts
@@ -247,27 +247,22 @@ describe('graceful shutdown supervisor contract', () => {
expect(daemon.slice(start, stop)).toContain('canAbortVerifiedExitedRemotePreparation(');
});
- it('publishes shutdown capability only after both signal handlers are installed', () => {
+ it('installs shutdown handlers before the attested descriptor rewrite', () => {
const descStart = daemon.indexOf('const desc: DaemonDescriptor = {');
const firstDescriptorWrite = daemon.indexOf('writeDaemonDescriptor(desc);', descStart);
const sigtermHandler = daemon.indexOf("process.on('SIGTERM'", firstDescriptorWrite);
const sigintHandler = daemon.indexOf("process.on('SIGINT'", sigtermHandler);
- const capabilityCommit = daemon.indexOf(
- 'desc.supervisorShutdownProtocol = SUPERVISOR_SHUTDOWN_PROTOCOL;',
- sigintHandler,
- );
const ipcHandlerReady = daemon.indexOf('setSupervisorShutdownHandler({', sigintHandler);
- const attestedWrite = daemon.indexOf('writeDaemonDescriptor(desc);', capabilityCommit);
+ const attestedWrite = daemon.indexOf('writeDaemonDescriptor(desc);', ipcHandlerReady);
expect(descStart).toBeGreaterThanOrEqual(0);
expect(firstDescriptorWrite).toBeGreaterThan(descStart);
- expect(daemon.slice(descStart, firstDescriptorWrite))
- .not.toContain('supervisorShutdownProtocol: SUPERVISOR_SHUTDOWN_PROTOCOL');
+ expect(daemon).not.toContain('supervisorShutdownProtocol');
+ expect(daemon.slice(descStart, firstDescriptorWrite)).toContain('sessionStoreProtocol');
expect(sigtermHandler).toBeGreaterThan(firstDescriptorWrite);
expect(sigintHandler).toBeGreaterThan(sigtermHandler);
expect(ipcHandlerReady).toBeGreaterThan(sigintHandler);
- expect(capabilityCommit).toBeGreaterThan(ipcHandlerReady);
- expect(attestedWrite).toBeGreaterThan(capabilityCommit);
+ expect(attestedWrite).toBeGreaterThan(ipcHandlerReady);
});
it('keeps supervisor shutdown host-authenticated and exact boot/birth bound', () => {
diff --git a/test/trigger-session-root-message.test.ts b/test/trigger-session-root-message.test.ts
index 79793bfdd2..8c89b2ae33 100644
--- a/test/trigger-session-root-message.test.ts
+++ b/test/trigger-session-root-message.test.ts
@@ -48,14 +48,13 @@ const mockQueuedTailAdmission = vi.fn();
const activeKeyLocks = vi.hoisted(() => ({
byMap: new WeakMap