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, Map>>(), })); -vi.mock('../src/core/worker-pool.js', () => ({ +vi.mock('../src/core/worker-pool.js', async (importOriginal) => { + const actual = await importOriginal(); + return { forkWorker: (...args: any[]) => mockForkWorker(...args), sendWorkerInput: (ds: any, payload: any, turnId?: string, opts: any = {}) => { if (!ds.worker || ds.worker.killed) return false; - const gated = ds.session.queuedActivationPending === true - || (ds.session.queuedActivationTail?.length ?? 0) > 0 - || (ds.initialStartPending === true && ds.session.queuedActivationInput !== undefined); - if (gated) return mockQueuedTailAdmission(ds, payload, turnId, opts); + if (actual.hasQueuedActivationAdmissionGate(ds)) return mockQueuedTailAdmission(ds, payload, turnId, opts); ds.worker.send({ type: 'message', content: typeof payload === 'string' ? payload : payload.content, @@ -69,9 +68,8 @@ vi.mock('../src/core/worker-pool.js', () => ({ }); return true; }, - hasQueuedActivationAdmissionGate: (ds: any) => ds.session.queuedActivationPending === true - || (ds.session.queuedActivationTail?.length ?? 0) > 0 - || (ds.initialStartPending === true && ds.session.queuedActivationInput !== undefined), + hasQueuedActivationAdmissionGate: actual.hasQueuedActivationAdmissionGate, + admitQueuedActivationTail: actual.admitQueuedActivationTail, getCurrentCliVersion: vi.fn(() => 'test-cli-version'), withActiveSessionKeyLock: vi.fn(async (map: Map, key: string, action: () => any) => { let locks = activeKeyLocks.byMap.get(map); @@ -98,7 +96,8 @@ vi.mock('../src/core/worker-pool.js', () => ({ }, closeSession: vi.fn(async () => ({ ok: true, outcome: 'closed', alreadyClosed: false, known: true })), getDaemonBootId: () => 'test-boot-id', -})); + }; +}); const mockRememberLastCliInput = vi.fn(); const mockGetAvailableBots = vi.fn(async () => []); @@ -132,7 +131,7 @@ vi.mock('../src/im/lark/card-handler.js', () => ({ import { buildExternalEventTopicMessage, triggerSessionTurn } from '../src/core/trigger-session.js'; import { sessionKey } from '../src/core/types.js'; -import { withActiveSessionKeyLock } from '../src/core/worker-pool.js'; +import { admitQueuedActivationTail, withActiveSessionKeyLock } from '../src/core/worker-pool.js'; import { resolveSessionReplyTarget } from '../src/core/reply-target.js'; const APP = 'app1'; @@ -181,22 +180,14 @@ describe('triggerSessionTurn rootMessageId target', () => { }); mockGetMessageChatId.mockResolvedValue(CHAT); mockQueuedTailAdmission.mockImplementation((ds: any, payload: any, turnId?: string, opts: any = {}) => { - const order = (ds.session.queuedActivationTailNextOrder ?? 0) + 1; - ds.session.queuedActivationTailNextOrder = order; - ds.session.queuedActivationTail = [ - ...(ds.session.queuedActivationTail ?? []), - { - id: `tail-${order}`, - order, - userPrompt: typeof payload === 'string' ? payload : payload.content, - cliInput: typeof payload === 'string' ? { content: payload } : payload, - turnId: turnId ?? `tail-turn-${order}`, - ...(opts.dispatchAttempt !== undefined - ? { dispatchAttempt: opts.dispatchAttempt } - : {}), - }, - ]; - mockUpdateSession(ds.session); + admitQueuedActivationTail(ds, { + userPrompt: typeof payload === 'string' ? payload : payload.content, + cliInput: typeof payload === 'string' ? { content: payload } : payload, + turnId: turnId ?? `tail-turn-${(ds.session.queuedActivationTail?.length ?? 0) + 1}`, + ...(opts.dispatchAttempt !== undefined + ? { dispatchAttempt: opts.dispatchAttempt } + : {}), + }); return true; }); mockCreateSession.mockImplementation((chatId: string, rootMessageId: string, title: string, chatType: 'group' | 'p2p') => ({ diff --git a/test/whiteboard-cli.test.ts b/test/whiteboard-cli.test.ts index 92ab46805b..5c5e08c7c2 100644 --- a/test/whiteboard-cli.test.ts +++ b/test/whiteboard-cli.test.ts @@ -22,9 +22,17 @@ afterAll(() => { }); function runCli(args: string[], input?: string): { status: number; stdout: string; stderr: string } { + // The child must not inherit a surrounding botmux session: the resolver + // rejects rows whose larkAppId differs from BOTMUX_LARK_APP_ID, and the + // session-scoped copy switches on BOTMUX_SESSION_ID / origin channel. + // Delete the keys — Node spawns `undefined` values as the string "undefined". + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, SESSION_DATA_DIR: dataDir }; + for (const key of ['BOTMUX_SESSION_ID', 'BOTMUX_LARK_APP_ID', 'BOTMUX_CHAT_ID', 'BOTMUX_ORIGIN_CHANNEL_ID', 'BOTMUX_READ_ISOLATED', 'BOTMUX_SEND_RELAY', 'BOTMUX_DAEMON_IPC_PORT']) { + delete env[key]; + } const r = spawnSync('node', [CLI_PATH, ...args], { cwd: home, - env: { ...process.env, HOME: home, USERPROFILE: home, SESSION_DATA_DIR: dataDir }, + env, input, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', diff --git a/test/whiteboard-unbind-session.test.ts b/test/whiteboard-unbind-session.test.ts index 41a8dd6dac..449ab3890d 100644 --- a/test/whiteboard-unbind-session.test.ts +++ b/test/whiteboard-unbind-session.test.ts @@ -128,16 +128,44 @@ describe('deleteWhiteboard session unbind', () => { expect(readPersistedSessionRows(tempDir, 'app1').s1.whiteboardId).toBe(board.id); }); - it('reports the session as unresolved when a daemon is visible but IPC fails', async () => { + it('reports the session as unresolved when a live lease exists and IPC fails', async () => { const board = createWhiteboard({ id: 'delete_abort', title: 't', larkAppId: 'app1', chatId: 'c1' }); seedBoundSession('app1', board.id); + seedOccupancyLease(tempDir, 'app1', { + ownerPid: 7, + bootId: 'boot-live', + leaseUntil: Date.now() + 60_000, + }); + ipc.daemon = { larkAppId: 'app1', ipcPort: 18765 }; + ipc.throws = true; + + const result = await deleteWhiteboard(board.id); + expect(result).toMatchObject({ clearedSessions: 0, unresolvedSessions: 1 }); + expect(result.reasons).toEqual(expect.arrayContaining(['daemon 在线'])); + expect(readPersistedSessionRows(tempDir, 'app1').s1.whiteboardId).toBe(board.id); + }); + + it('does not fall back to a host write when the daemon answers 500', async () => { + const board = createWhiteboard({ id: 'delete_500', title: 't', larkAppId: 'app1', chatId: 'c1' }); + seedBoundSession('app1', board.id); + ipc.daemon = { larkAppId: 'app1', ipcPort: 18765 }; + ipc.status = 500; + + const result = await deleteWhiteboard(board.id); + expect(result).toMatchObject({ clearedSessions: 0, unresolvedSessions: 1 }); + expect(ipc.fetches).toHaveLength(1); + expect(readPersistedSessionRows(tempDir, 'app1').s1.whiteboardId).toBe(board.id); + }); + + it('leaves the row unresolved with a reason when a fresh daemon has no lease', async () => { + const board = createWhiteboard({ id: 'delete_nolease', title: 't', larkAppId: 'app1', chatId: 'c1' }); + seedBoundSession('app1', board.id); ipc.daemon = { larkAppId: 'app1', ipcPort: 18765 }; ipc.throws = true; const result = await deleteWhiteboard(board.id); - // The liveness re-probe inside the store aborts the offline write, so the - // row is untouched — and the count says so instead of a bare 0. expect(result).toMatchObject({ clearedSessions: 0, unresolvedSessions: 1 }); + expect(result.reasons).toEqual(expect.arrayContaining(['后台 daemon 是升级前的旧进程,请先运行 `botmux restart`'])); expect(readPersistedSessionRows(tempDir, 'app1').s1.whiteboardId).toBe(board.id); });