Skip to content

refactor(session): 删除 daemon 侧 JSON 写路径,落盘改为行级 upsert - #1051

Draft
LucasIcarus wants to merge 16 commits into
masterfrom
feat/session-store-drop-json
Draft

refactor(session): 删除 daemon 侧 JSON 写路径,落盘改为行级 upsert#1051
LucasIcarus wants to merge 16 commits into
masterfrom
feat/session-store-drop-json

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

定位

会话架构 Stage 0(口径:docs/design/2026-08-12-session-restage-store-first.md):daemon 只写 SQLite,会话行改为行级 upsert。

不是全仓 db-only,也不是 occupancy / 单一 apply。跨进程在「CLI 已升级、daemon 尚未重启」的窗口内仍走 db-else-json;删除这条回落的条件写在设计文档 Stage 0,不在本 PR。

#852 把引擎换成 SQLite 之后,daemon 仍保留着整套 JSON 写路径(save() 整图序列化、JSON CAS、tmp+rename),session-store.ts 从约 1390 行涨到 2154 行。本 PR 删掉 daemon 侧这套 JSON 写实现:2154 → 1925。跨进程 JSON 读与离线写刻意保留(约 25 行),否则升级窗口内 botmux send 读不到会话。

本 PR 不依赖灰度已关闭:升级窗口内六项行为与 master 逐项一致(对照表见「升级窗口」)。


改了什么

1. daemon 不再写 JSON

删除:

  • save():整份 map 序列化 + tmp+rename + byte-identical 跳写
  • load() 里的 JSON 写回,以及 legacy → per-bot 的迁移写
  • readExistingSessionsFromDisk / readSessionsProjectionStrict
  • Remote lineage CAS + readback 的 JSON 实现
  • repairMissingChatScopes 及它每次 load 触发的写回事务
  • persistRow 回落到 save() 的分支

store 内的 withFileLockSync 只剩导入一处。导入经固定的 <db>.tmp 暂存:同一 bot 的两个 owner 进程短暂并存时(重启撞上尚未回收的前任),两边都会通过 existsSync(db) 并写同一个 tmp。换成 per-process 的 tmp 名会留下无人清理的孤儿文件;直接建进最终 .db 会打破「.db 存在 ≡ 导入已完成」这条不变量。

sessions-*.json 降为一次性导入源:拥有该 bot 的 daemon 导入之后不再写它。其它进程在该 daemon 尚未导入时仍可读、可离线写,且任何路径都不得创建空 .db——空库会让导入门误判为已完成。

2. 导入期做一次性归一化,且不按 sessionId 重键

stripLegacyPendingCardFields / repairMissingChatScope 只在导入时执行一次。loadAllSessionsSnapshot 仍做纯内存归一化(它读的是别人的 store,不写盘)。

不按行内 sessionId 重键:两条 JSON 记录可以带同一个 sessionIdINSERT OR REPLACE 会让后写的 closed 幽灵覆盖活行;导入只跑一次、JSON 随后冻结,这一步不可逆。改为按文件原 key 导入,错键行保持不可达(身份扫描本就跳过 key 与 sessionId 不符的行)。

3. close / reactivate / mojo journal:先落盘,再合并回内存

原先在活对象上就地修改,失败再逐字段还原(close 14 个字段、reactivate 20 个、mojo 2 个)。改为:

const next: Session = { ...session };
next.status = 'closed';
persistRow(next);             // 抛错 → 内存未改
Object.assign(session, next); // 提交后才落内存

Object.assign 而不是替换 map 引用,保留 DaemonSession 的共享别名。既有三条回滚回归(Remote lineage / previewTarget / mojo park)断言未改,继续绿。

4. 读写方式打开会创建空库

new DatabaseSync('<missing>.db') 会把文件建出来;空库让 existsSync(db) 为真、跳过 JSON 导入,迁移前的会话从所有活路径上消失。

  • openDbForRead 对不存在的路径直接抛错
  • withOwnStoreDb 复用 loadForWrite() 已附着的连接,不再自开
  • mutateSessionRowOffline 的 sqlite 路径在 openDbForOwnStore 前再 existsSync 一次,缺文件返回 undefined

5. 删除已失效的等待上限参数

Remote 批量 API 的 options.maxWaitMs 删除:它约束的是 JSON 文件锁的等待,SQLite 路径上从未生效。等待上限由连接级 busy_timeout(3s)决定。fleet shutdown 的 remaining <= 0 预检保留。

6. 沙盒授权

readOnly 授权为「session-stores/<appId> 目录 + 冻结的 sessions-<appId>.json」。授目录而不是三个文件,是因为单文件 bind 会钉死 WAL sidecar 的 inode,而 SQLite 在 daemon 重启时会删建 -wal/-shm——长驻的沙盒面板会一直读着已死的 WAL。兄弟 bot 的 store 目录仍 deny-by-default;测试补了 sibling 目录、共享父目录、spawn 之后才出现的库三条 none 断言。

升级窗口内沙盒里的 botmux send 仍要 stat 得到那份冻结 JSON,所以该授权暂不撤。

7. 删除白板:daemon 在走 IPC,不在才离线写

clearSessionWhiteboardRefs 原先按 sessions*.json 筛文件名再整份改写。#852 之后行已在 SQLite,扫描匹配不到文件,deleteWhiteboard 返回 clearedSessions: 0,而 whiteboardId 仍指向已删的板。

改为走 store 后还有三个问题要收:

  • 解绑必须按板 id 比对。 删板会先把板移出 index,daemon 的 ensureSessionWhiteboard 因此会在该会话下一轮立刻补一块新板。无条件发 whiteboardId=null 会把这块新绑定一起抹掉,并留下一块没人指向的孤儿板。路由新增 expectWhiteboardId,不匹配返回 409;离线路径本来就有比对,两侧口径现在一致。
  • 未能解绑要报出来。 daemon 可见但 IPC 不可用时,离线写会被存内探测中止——这是对的(不能绕过持有内存缓存的 daemon),但结果不该和「没有会话引用这块板」一样报 0。这类会话计入 unresolvedSessions 返回。
  • IPC 要有超时。 心跳新鲜但 socket 不响应的 daemon,不能把 dashboard 的删除请求一直挂住。

行为分支:daemon 运行中 → IPC 带 CAS 解绑;daemon 不可见 → 离线写;IPC 失败且 daemon 仍可见 → 不写盘,计入 unresolvedSessions

8. 离线写与 daemon 发现各收敛成一份实现

新增 services/session-offline-write.ts#mutateSessionRowWhenUnowned:把「离线写的前提是没有 daemon 持有这一行」收成一个入口,cli.tswhiteboard-store 共用,探测与 store 读写用同一个 dataDir。此前每个调用方各自拼一遍 abortIf,本 PR 的白板路径第一版就漏了。

cli.ts 私有的心跳目录解析与 90s 过期判定(与 utils/daemon-discovery.ts 逐字重复)删除,改用共享实现;listOnlineDaemons / findOnlineDaemon 增加可选 dataDir 参数。设计文档 Stage 1 要求「心跳探测不再作为所有权判断」,收敛后只剩一个入口要删。src/cli.ts 净 −38 行。

9. 冻结的 JSON 文件

磁盘上原地保留、不改名。daemon 导入后运行时不再写它;升级窗口内其它进程仍可能读;它也是回退到迁移前版本时的数据副本。改名会让回退后的旧 daemon 从空库起步,再写出一份新 JSON。


一并修复的三个 #852 遗留问题

A. Bun 上导入发布出只有文件头的库

暂存库开着 WAL,而 renameSync 只搬走主文件。bun:sqlite 关连接时不把 -wal 折回主文件,行留在 sessions.db.tmp-wal 里,发布出去的是 4 KB 的文件头;之后每次打开报 disk I/O error。导入门是 existsSync(db),这个壳不会被重建——迁移前的会话在所有活路径上消失。

修复前 修复后
Bun 1.4.0(本分支) sessions.db=4096 + 孤儿 tmp-wal/tmp-shmlistSessionsStrict()disk I/O error sessions.db=24576,行可读回,无 tmp 残留
Bun 1.4.0(master) 同样复现(#852 起导入逻辑未变)
Node 24 正常 正常

暂存库改用 PRAGMA journal_mode = DELETE,正式库仍是 WAL。

master 上跑编译版、且该 bot 还没完成过导入的安装会踩到。若本 PR 合入偏慢,这一处可以单独先合。

B. 删除白板后 whiteboardId 仍指向已删的板

见上「7」。原因是按文件名筛 sessions*.json 的扫描在 #852 之后匹配不到任何文件,而测试夹具当时写的正是 JSON,所以一直绿。

C. Node 22 加载 node:sqlite 时向 stderr 打出 ExperimentalWarning

ExperimentalWarning: SQLite is an experimental feature and might change at any time

pty 合流后这两行进入 botmux list 的备用屏,把钉住的标题挤出第 0 行;CI(Node 22)上 picker 断言读到空行。Node 24 已不发此警告,本机 24 复现不了。#852 之后凡是打开 .db 的 CLI 都会污染 stderr,本 PR 让 botmux list 也打开库,才在 CI 暴露。

修法是在 sqlite-compatrequire 期间过滤该警告,require 结束即还原——process.on('warning') 拦不住 Node 的默认打印。回归:test/sqlite-compat-warning.test.ts(Node 24 上恒绿,真正的拦截在 CI 的 Node 22)。


身份扫描

readSessionRowCopiesAcrossStores 要求恰好一份。legacy → per-bot 一直是复制而不删除,导入完成后若把冻结 JSON 也算一份,会把合法会话判成重复。本 PR 在已有 .db 时不再把同一 bot 的冻结 JSON 算作第二份。

未导入、尚无 .db 时 JSON 回落仍在,sessions-evil.json 这类按文件名冒充 store 的路径并未从窗口内消失。


升级窗口

初版曾把跨进程也改成只读 SQLite,前提是「灰度已关闭」。这个前提不成立:npm i -g 的 postinstall 会立刻改写 ~/.botmux/bin/botmux,而 daemon 仍跑着旧代码;自动更新默认关闭,botmux upgrade 只提示用户自己 botmux restart。窗口没有上界,每个从 JSON 写者升级上来的用户都会经过一次。

同一份仅有 JSON 的数据目录,逐项对照:

窗口内操作 master 跨进程 db-only(已撤回) 现在
CLI 快照读(botmux send / list ['s1'] 抛错 ['s1']
CLI 点读 LIVE undefined LIVE
身份扫描 1 份 0 份 1 份
worker getSession LIVE undefined LIVE
worker getSessionFresh LIVE undefined LIVE
CLI 离线写 ok → closed 抛错 ok → closed

所以只删 daemon 的 JSON 写路径。跨进程读、离线写、owner: false 的只读 load、沙盒对冻结 JSON 的授权都保留。删除这些分支的条件写在设计文档 Stage 0:升级后自动重启 fleet 落地,或 2026-11-26 的兜底复核点。

老版本升到本版并重启 daemon 后,一次性导入仍会带上 active 与 closed 行(有回归覆盖)。


影响面

  • 主要改 session-storewhiteboard-storedashboard-ipc-server;新增 services/session-offline-write.ts;另及 cli.ts 的 daemon 发现、utils/daemon-discovery.tsfs-policyremote-shutdown-detach 的调用签名。
  • 跨进程:daemon 在线时写 SQLite;worker owner: false 走 db-else-json 只读、不建库;CLI 离线写在已有 .db 时走 BEGIN IMMEDIATE + 心跳探测(SQLite 锁不替代「daemon 内存与磁盘分叉」的探测),未导入则写 JSON;跨 bot 发现同样 db-else-json。dashboard 删白板:daemon 在则 IPC 带 CAS 解绑,不在才离线写。
  • 跨平台:Linux bwrap / macOS Seatbelt 仍授 store 目录(单文件 bind 会钉死 WAL sidecar 的 inode)。
  • 跨 CLI / 后端 / 会话类型updateSession 等签名不变;Pty/Tmux、话题会话/群会话、adopt/restore、sandbox on/off 仍走同一套 store 导出。
  • cli.ts 的 daemon 发现换实现:约 35 处调用点行为不变(同一个心跳目录、同样的 90s 过期、同样的字段),差异只在数据目录改为显式传入。
  • 旁路存储未改:turn-sends / frozen-card / whiteboard 正文 / usage-ledger / idempotency / vc-meeting-* / schedule / pending-turn journal;utils/file-lock.ts 本体未动。
  • occupancy(库内租约)和单一 apply 不在本 PR。心跳探测在 CLI 离线写与删白板离线路径上保留。

已知边界

  • 离线写在 SQLite 路径只改一行,不再顺手删除错键行。
  • 跨进程 JSON 读分支仍在(约 25 行)。删除条件见设计文档 Stage 0,不是「感觉灰度稳了」。
  • Remote 批量的等待上限固定为 busy_timeout 3s。
  • restoreActiveSessions 仍走 listSessions():损坏库会被当成空投影启动(写路径有 listSessionsStrict)。与 feat(session): session-store 引擎替换为 per-bot SQLite(导入 + 混合窗口 + sqlite-compat) #852 相同,本 PR 未改。
  • 删白板时若某个会话的 daemon 可见但 IPC 不可用,该行的绑定会留在盘上指向已删的板。它不是坏数据:daemon 的 ensureSessionWhiteboard 会在该会话下一轮补上新板。本 PR 只保证这种情况被计数报出,不静默。

测试与验证

未跑 switch:here,未重启 live daemon。

  • tsc --noEmit -p tsconfig.json 干净;bun run build 通过。
  • CI 全绿(build / bun-binary / bun-binary-musl / CodeQL / 三个 Analyze)。build 在 Linux + Node 22 上跑完 18746 条。
  • 全量单测在 Node 22(CI 版本)、两侧都先 bun run build,与 merge-base origin/master@cc5085b6 本机同环境对照:baseline 37 条失败(全部环境性,e2e / 权限位 / 端口一类),本 PR 39 条,差集 2 条,都在 test/mojo-close-admission-fence.test.ts
    这两条判定为本机满载下的抖动,依据:失败点是 vi.waitFor 的默认 1s 超时在等 fake mojo 子进程的 stdout(expected undefined to be 'sid-torn');隔离重跑 3/3 通过;CI 在本 PR 最终 commit 上跑同两个文件通过。CI 也曾在中间 commit 上红过同族的 test/mojo-close-worker-journal.integration.test.ts 一次,rerun 同一 commit 即通过。
  • test/session-store.test.ts + test/session-store-sqlite.test.ts 全绿。
  • 白板解绑:test/ipc-whiteboard-route.test.tstest/whiteboard-unbind-session.test.ts 全绿。
  • Bun 1.4.0 与 Node 24 各跑一次真实导入:均为 24576B、行可读回、无 .tmp 残留(修复前 Bun 上是 4096B 空壳 + 孤儿 sidecar + disk I/O error)。
  • git merge-tree origin/master HEAD 无冲突。

对照方法上漏过两次:① 没先 bun run build,依赖 dist/ 的测试被跳过,没看到白板绑定清不掉;② 本机 Node 24,没看到 ExperimentalWarning。最终口径是同版本 Node + 两侧都 build + 逐条 diff。

新增回归覆盖:升级窗口内的 snapshot / 点读 / 身份扫描 / worker 读 / 离线写;owner: false 的 load 只读;botmux delete 在仅有 JSON 时写回 JSON 且不建 .db;snapshot 与离线写都不创建 store;已导入 store 的冻结 JSON 不算身份扫描第二份;导入时剥掉 legacy 卡片字段;同 sessionId 的两行不得互相覆盖;白板解绑的 CAS、409 不回落写盘、IPC 失败计入 unresolvedSessions、无 larkAppId 的 legacy 行不探测 daemon。

夹具:seedPersistedSessionRows / sessionStorePath 播种真实 SQLite。断言数量只增不减。


与其它 open PR

session-store.ts 上还有 #925 / #1001 / #956,base 都在 #852 之前,本来就需要 rebase。

#925 要解决的是 save() 每次序列化整个 map、closed 行只增不减、撞上 V8 字符串上限。本 PR 删除 save() 之后行级 upsert 是 O(1),那条立项理由不再成立。若仍要回收 closed 行,应该是 DELETE FROM sessions WHERE status='closed' AND ...

#1001 / #956 只加函数,rebase 即可。

@LucasIcarus
LucasIcarus force-pushed the feat/session-store-drop-json branch from dd8a0b9 to a24e43f Compare August 27, 2026 20:04
Step 3 第二 PR 的主体。#852 把引擎换成 SQLite 但保留了整套 JSON 机器做回滚
窗口,代价是 session-store.ts 从 1390 涨到 2154 行,暂时违反分步原则 1
「合入即更简单」。这一刀把账还上:2154 → 1833 行,删的全是机器。

删除的 JSON 机器:save() 整份 map 序列化 + tmp+rename + byte-identical 跳写、
load() 的 JSON 分支与 legacy→per-bot 迁移分支、readExistingSessionsFromDisk /
readSessionsProjectionStrict、Remote lineage CAS + readback 的 JSON 实现、
mutateSessionRowOffline 的 JSON 分支,以及 StoreFileRef.kind 撑起来的整套
db-else-json 分流。sessions*.json 降级为一次性导入源,运行时读路径不再看它。

withFileLockSync 只剩导入一处(四处编排消失),刻意不全删:导入经固定的
<db>.tmp 暂存,同一 bot 的两个 owner 进程可能短暂并存,两边都会通过
existsSync(db)、写同一个 tmp、发布出损坏库。per-process tmp 名会留下没人清的
孤儿文件;直接建进最终 .db 会打破「.db 存在 == 导入已完成」这个不变量。

顺带消掉的 ad-hoc 防御:
- closeSession / reactivateClosedSession / mutateMojoCloseJournal 的手工回滚
  (分别抄 14 / 20 / 2 个 prior 字段再逐字段还原)改为「先持久化副本、提交
  成功后 Object.assign 回活对象」,失败路径变成什么都没发生。用 Object.assign
  而非替换 map 引用,保住 DaemonSession 的别名语义。
- stripLegacyPendingCardFields / repairMissingChatScopes 从每次写、每次 load
  收敛到导入期一次;key-mismatch 改为导入时按行自身 sessionId 归位(修好而
  不是丢掉)。
- Remote 两个批量 API 的死参数 maxWaitMs 删除(它约束的文件锁已不存在),
  fleet shutdown 的 deadline 预检保留。

两处只有删掉 JSON 分支才暴露的建库地雷:SQLite 读写 open 会创建文件,而空库
会让 daemon 的 existsSync(db) 导入门判假、静默丢掉整份迁移前的会话。已封:
openDbForRead 对不存在的路径直接抛错、mutateSessionRowOffline open 前先
existsSync、withOwnStoreDb 不再自开连接改走 loadForWrite()。

新增 SessionStoreNotImportedError:「有 JSON 没有 .db」意味着拥有该 bot 的
daemon 还在跑迁移前的版本,snapshot(仅调用方自己的 store)、离线写、
owner:false 的 load 都 fail-closed 报可行动错误,而不是静默空投影。

沙盒收面:fs-policy 不再授权冻结的 sessions-<appId>.json,只授权
session-stores/<appId> 目录,兄弟 bot 仍 deny-by-default。

影响面:主要动公共层 session-store,另及 fs-policy 与 remote-shutdown-detach
两处调用签名。updateSession 等调用点签名不变,Pty/Tmux、话题/群、adopt/restore、
sandbox on/off 仍走同一门面。turn-sends / frozen-card / whiteboard /
usage-ledger / idempotency / vc-meeting-* / schedule / pending-turn journal
等旁路存储未卷入,utils/file-lock.ts 本体未动。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
sessions*.json 不再是运行时读路径,凡是把它当会话持久层的测试夹具全部换成
播种真实的 session-stores/<appId>/sessions.db(legacy 无 appId 落扁平
sessions.db)。test/helpers/session-store-disk.ts 新增 seedPersistedSessionRows
与 sessionStorePath,去掉不再需要的 db-else-json 回落。

覆盖了 16 个测试文件,断言零削弱:it 与 expect 计数只增不减,fs-policy 的
授权期望跟随 src 收紧并额外加了三条 sibling deny 断言(sibling store 目录本身、
共享父目录 data/session-stores、spawn 后才出现的新 bot 库)。

新增回归:
- 未导入的 store 在 snapshot / 离线写 / owner:false load 三条路径上分别
  fail-closed,且报「迁移」错误而非「引擎能力」错误——两者必须分型,前者要
  重启 daemon,后者要升级运行时;
- botmux delete 端到端:只有冻结 JSON 时必须响,不得输出「没有活跃会话」;
- loadAllSessionsSnapshot 与 mutateSessionRowOffline 绝不创建 store
  (导入门投毒回归);
- 冻结的 pre-SQLite JSON 不构成身份扫描的第二份拷贝;
- 未导入的 peer 对跨 bot 发现不可见;
- 导入期收敛:legacy 卡片字段被剥掉、key-mismatch 行按自身 sessionId 归位。

被删掉的两条用例覆盖的是随 JSON 引擎一起消失的行为(「JSON store 也能读」、
「离线写顺手收敛整个文件」),已就地改写成覆盖新语义的等价用例,不是丢覆盖。

验证:tsc 干净;vitest run --project unit 全量与 origin/master@0265234d 同环境
基线逐条对照,新增失败为 0(基线本身有一批环境性失败:文件权限、符号链接、
Unicode 宽度等,两边完全一致)。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
- 补「执行结果(2026-08-28)」:原则 1 在本步兑现,逐条列出删掉了什么、
  哪一处刻意没删(导入的文件锁)以及为什么;
- 冻结 JSON 的待决项 6 定性:原地保留不删不改名——它已不被任何运行时路径
  读取,代码成本为零,而它是回退到迁移前版本时唯一还能读的副本;
- 记录一个正向副作用:legacy→per-bot 一直是复制不删除,所以「两份拷贝 →
  拒绝推断当前调用者」是常态假歧义,db-only 之后消失,并堵掉「往 dataDir
  放一个 sessions-evil.json 冒充身份来源」的伪造面;
- 明写本步接受的边界:从迁移前版本直连本版且旧 daemon 仍在跑时该 bot 没有
  .db,跨进程读写 fail-closed 直到 daemon 重启;跨 bot 发现看不见未导入的
  peer;离线写不再收敛整个 store;
- Step 4(a) 标记为已随本步落地,不再单独立项;
- Step 4(c) 补复核结论:形状与 (a) 相同但不能照搬——(c) 在 store 外部,只能
  调 updateSession(session),传副本会把 map 条目换成副本、切断 ds.session 的
  别名;为省 4 行还原代码而新增一个「按 patch 更新」的公共写入口不划算。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
对抗式复核的产出,四条:

1. 一次性导入在 Bun 上会产出空壳库,把导入门永久钉死。暂存库原本开 WAL,
   而 renameSync 只搬走一个文件——bun:sqlite 关连接时不把 -wal 折回主文件,
   于是行全留在 sessions.db.tmp-wal 里,发布出去的是 4KB 只有文件头的库,
   之后每次打开都报 disk I/O error;因为导入门是 existsSync(db),这个壳再也
   不会被重建,迁移前的会话在所有活路径上消失。暂存改用回滚日志
   (journal_mode = DELETE)让文件自包含,真正的库仍然跑 WAL。
   实测:修复前 bun 下 4096B + 孤儿 tmp-wal/tmp-shm 且 listSessionsStrict 抛错,
   修复后 bun 与 Node 均 24576B、两行都读得回、无 tmp 残留。
   这条在 master 上同样复现(导入逻辑自 #852 起未变),不是本分支引入。

2. 未导入判定会把「共享的 legacy sessions.json」误当成本 bot 待导入。
   legacy→per-bot 迁移从不删除那个文件,任何升级过的安装上都常驻,而且没有
   任何 daemon 会导入它(生产 daemon 一律带 appId 启动)。结果是每个新加的
   bot——本来就无物可导——都会在 botmux send / 离线写上硬报错,还让人去重启
   一个本来就是最新的 daemon。判定收窄为「只看本 store 自己的
   sessions-<appId>.json」。

3. 身份扫描的空结果会被读成「行不存在」。调用方把 0 命中变成「未找到当前
   进程所属 session」,把人引去查进程标记,而真实原因是某个 bot 的库还没导入。
   零命中时若存在待导入的 per-bot store,改抛 SessionStoreNotImportedError
   并点名具体文件。

4. getSessionFresh 在未导入窗口里静默返回 undefined:worker 的
   authorizeManagedSend 会把它降级成 origin_mismatch,codex-app 会话整轮回信
   发不出去且日志里看不出原因。worker 的 IPC 处理器没有 catch,抛错会更糟,
   所以补一次性 warn 说清楚。

回退:把导入按行自身 sessionId 重键那一条改回按文件原 key。两条 JSON 记录
可能带同一个 sessionId,重键会让后写的 INSERT OR REPLACE 掉前一条——陈旧的
closed 幽灵行覆盖活行,整行 JSON 全被替换,且导入只跑一次、JSON 随后冻结,
不可逆。实测两个 checkout 跑同一份输入确认。错键行改为保持惰性存在:身份
扫描本来就跳过 key 与 sessionId 不符的行。

新增回归:发布出去的库必须自包含(暂存物不得残留)、新 bot 不得被误判为
未导入、空扫描在存在待导入 store 时必须报迁移错误、同 sessionId 的重复记录
不得互相覆盖。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
CI(Linux + bun,且先 build 出 dist)暴露出来的:`clearSessionWhiteboardRefs`
自己 readdir 数据目录、按 `sessions*.json` 前后缀筛文件、整份读改写。这是 store
唯一门之外的第二个会话行写者,靠动态拼名字躲过了历次 grep。

后果是它在 #852 之后就已经**静默失效**——行搬进 SQLite 后这个扫描一个文件都
匹配不到,`deleteWhiteboard` 报 `clearedSessions: 0`,而每一行的 `whiteboardId`
都还指着已经删掉的板。测试之所以一直绿,是因为夹具写的正是 JSON。

改为经 `loadAllSessionsSnapshot` 找到引用该板的行、逐行 `mutateSessionRowOffline`
清除。`deleteWhiteboard` 跑在 dashboard 进程里,那个进程不持有会话 store,所以
离线行突变正是对的入口。读不到的 store(不可读、或某个 bot 还没迁移)跳过该行,
不让整次删除失败。

同时补上两个仍在播种 sessions*.json 当持久层的测试夹具:whiteboard-cli 与
api-only-cli-gate。这两个文件都依赖 `dist/`,本机没 build 时会跳过或整文件报错,
所以上一轮本地全量对照没看见它们——CI 先 build 再跑,才露出来。

api-only-cli-gate 原来往单个 sessions.json 里 push 一个**数组**(靠 JSON 读侧
容忍数组形状);db-only 下没有这种形状,改成按 larkAppId 分库播种。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
@LucasIcarus
LucasIcarus force-pushed the feat/session-store-drop-json branch from a24e43f to 88eb2ab Compare August 27, 2026 20:15
CI(Node 22)上 8 条 picker 断言读到空的第 0 行。往终端缓冲里打诊断之后真相是:
Node 22 首次加载 node:sqlite 会往 stderr 打两行

  (node:PID) ExperimentalWarning: SQLite is an experimental feature ...
  (Use `node --trace-warnings ...` to show where the warning was created)

pty 把 stderr 与 stdout 合流,这两行落进 `botmux list` 的备用屏全屏 UI,把钉住的
标题挤出屏幕——所以第 0 行是空的。本机 Node 24 已经不发这个警告,所以本地怎么跑
都复现不了;CI 的 setup-node 钉的是 22。

这不是测试问题:任何碰会话库的 agent 侧 botmux 命令都会被污染 stderr,交互式
picker 直接被挤掉一行。#852 之后 master 上就已经如此(凡是打开 .db 的命令),
本 PR 让 `botmux list` 也走上这条路,才把它暴露出来。

修法放在 sqlite-compat —— 全仓打开 SQLite 的唯一入口:require 期间临时过滤 emit,
只丢弃 type 为 ExperimentalWarning 且文案提到 SQLite 的那一条,require 完立刻还原。
加 process.on('warning') 监听器挡不住,Node 的默认打印照跑,所以必须在 emit 处过滤。

新增回归 test/sqlite-compat-warning.test.ts:子进程加载引擎,断言 stderr 里没有
ExperimentalWarning。反向变异确认有牙:同一条用例在 master 的 sqlite-compat 上
(Node 22)实测失败。用例注释写明把关能力依赖运行时——Node 24 恒绿,真正拦截
发生在 CI 的 Node 22。

验证:Node 22 下全量单测与 origin/master 同环境基线(两侧都先 build)逐条对照,
失败集合完全一致(各 23 条),新增失败为 0。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
@LucasIcarus
LucasIcarus force-pushed the feat/session-store-drop-json branch from 88eb2ab to 07085c7 Compare August 27, 2026 20:15
推翻本步原计划的第 5 条「mutateSessionRowOffline / 各扫描函数的 JSON 分支收敛为
db-only」。那条依赖一个不成立的前提:**灰度窗口会关闭**。

实际上 npm 装完的瞬间 postinstall 就把 ~/.botmux/bin/botmux 指到新二进制,而拥有
会话行的 daemon 还在跑旧代码——自动更新默认关闭、botmux upgrade 只提示用户自己
botmux restart,所以「已升级、未重启」这段窗口**没有上界,且每个老用户升级时都会
经历一次**,不是少数落后者的边角。

db-only 在这个窗口里的实测后果(同一份 JSON-only 数据目录,与 master 的 A/B):
CLI 快照读 THREW、CLI 点读 undefined、身份扫描 0 份、worker getSession undefined、
worker getSessionFresh undefined、CLI 离线写 THREW —— 六项全断。窗口内会话里的
agent 连 botmux send 都发不出去,等于回不了话。

改回来的部分(窗口内行为与 master 逐字一致,六项实测相同):
- StoreFileRef.kind 与 resolveStoreFile / listStoreRefs 的 db-else-json 解析;
- readStoreEntries / readStoreRowByKey / readStoreActiveRows / countActiveSessionsOnDisk
  的 JSON 分支;
- getSessionFresh 的 JSON 回落;
- mutateSessionRowOffline 的 JSON 分支(那台旧 daemon 还在读这份 JSON,写别处等于
  在它背后分叉;而且在这里建空库会关掉它的一次性导入门);
- load() 给 owner:false 的 JSON 分支,但**只读**——scope 修复与 legacy 迁移写回都是
  拥有者 daemon 的活,它在导入时做一次;
- fs-policy 的 sessions-<appId>.json 授权。撤掉它会让窗口内沙盒里的 botmux send
  连 stat 都做不到那份 JSON,而沙盒是 agent 的主路径,等于把刚修好的窗口兼容在最
  常用的那条路上又废掉。

一并删掉我为了兜住那个错误前提而加的 SessionStoreNotImportedError / assertStoreImported /
listUnimportedStoreJsonPaths / getSessionFresh 的一次性 warn —— 有了回落就不需要
「响一声」了。

仍然删除的(daemon 侧 JSON 引擎,与窗口无关,因为导入由 daemon 自己保证):
save() 整份序列化 + tmp+rename + byte-identical 跳写、load() 的 JSON 写回与
legacy→per-bot 迁移写、Remote lineage CAS + readback 的 JSON 实现、
readExistingSessionsFromDisk / readSessionsProjectionStrict、repairMissingChatScopes
与它每次 load 的写回事务、persistRow 的 save() 兜底。

代价是 session-store.ts 少删约 60 行,换来这个 PR 不再依赖任何灰度假设,什么时候
合都安全。迁移本身从来没受影响:老版本用户升级后重启 daemon,一次性导入照常把
active 与 closed 行全部带过来(回归用例覆盖)。

验证:Node 22(CI 钉的版本)全量单测,两侧都先 build,与 origin/master 基线逐条
对照失败集合完全一致(各 23 条),新增 0。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
@LucasIcarus LucasIcarus changed the title refactor(session): 会话库净删除 JSON 引擎,收敛为 db-only(Step 3 第二 PR) refactor(session): 删除 daemon 侧 JSON 写路径,会话行改为行级 upsert Aug 28, 2026
SQLite 合入后以本机 virtual actor 为稳态:occupancy 进库、单一 apply、
per-session 串行。去掉「每步必须立刻变简单」及旧 Step 1–5 路线图。
daemon 运行中通过 whiteboardId=null 清会话绑定,避免 dashboard 直写 SQLite
与内存缓存分叉。daemon 不可见时才 mutateSessionRowOffline,并在锁内探测
findOnlineDaemon。sqlite 离线写在 open 前拒绝缺文件,防止空库挡住导入。
@LucasIcarus
LucasIcarus force-pushed the feat/session-store-drop-json branch from dba31a1 to cff59cb Compare August 28, 2026 04:32
离线写会话行的前提是「没有 daemon 持有这一行」,探测这件事此前由每个调用
方各自拼一遍 abortIf。新增 session-offline-write.ts 把该前提收成一个入口
mutateSessionRowWhenUnowned,探测与 store 读写共用同一个 dataDir。

cli.ts 私有的心跳目录解析与 90s 过期判定删除,改用 utils/daemon-discovery;
listOnlineDaemons / findOnlineDaemon 增加可选 dataDir 参数,调用方能把探测
钉在自己已解析的数据目录上。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
删板会先把板移出 index,daemon 的 ensureSessionWhiteboard 因此会在该会话
下一轮立刻补一块新板。原来的 IPC 解绑无条件发 whiteboardId=null,会把这块
新绑定一起抹掉,并留下一块没人指向的孤儿板;离线路径本来就有比对,两侧口径
不一致。路由新增 expectWhiteboardId,不匹配返回 409,调用方据此跳过。

因 daemon 可见而没能解绑的会话计入 unresolvedSessions 返回,不再与「没有
会话引用这块板」一样报 0。解绑的 daemon IPC 加超时,避免心跳新鲜但 socket
不响应的 daemon 把 dashboard 的删除请求挂住。离线写改走
mutateSessionRowWhenUnowned。

回归:解绑 CAS、409 不回落写盘、IPC 失败计入 unresolved、无 larkAppId 的
legacy 行不探测 daemon。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
Stage 0 原先只写「等升级后自动重启 fleet」,而该实现不在本文范围也没有时间
点,等于把跨进程 JSON 读做成本文自己禁止的长期不变量;补一个 2026-11-26 的
兜底复核点。

Stage 1 验收改为点名 findOnlineDaemon 及其调用点,并说明 dashboard/registry
读同一批心跳文件但不参与所有权判断。

补记沙盒内 CLI 取不到租约:它对会话库只有 readOnly 授权,也读不到 IPC
secret,只能发命令。Stage 2 必须区分宿主 CLI 与沙盒 CLI 两类调用方。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
unresolvedSessions 的注释补上「行在写入前已消失」这一种情况;设计文档里
残留的「等 fleet 自动重启」表述统一改为指向 Stage 0 的两条删除条件。

Claude-Session: https://claude.ai/code/session_014iArpG1y1ASTutun3rDYUa
@LucasIcarus LucasIcarus changed the title refactor(session): 删除 daemon 侧 JSON 写路径,会话行改为行级 upsert refactor(session): 删除 daemon 侧 JSON 写路径,落盘改为行级 upsert Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant