Skip to content

feat(session): 收口跨进程会话库协议并补齐升级可见性 - #1342

Open
LucasIcarus wants to merge 10 commits into
masterfrom
feat/session-endstate-store-close
Open

feat(session): 收口跨进程会话库协议并补齐升级可见性#1342
LucasIcarus wants to merge 10 commits into
masterfrom
feat/session-endstate-store-close

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 跨进程会话读写只认 per-bot SQLite。sessions-<appId>.json.db 时判定为 unmigrated(只看文件是否存在,不解析内容),不再走 db-else-json,也不再为窗口保留读旧格式。
  • 心跳探针改为只拒绝、带原因(lease / legacy_daemon / daemon_without_lease / store_unreadable)。descriptor 广告 sessionStoreProtocol 与仅用于文案的 botmuxVersion;停写 supervisorShutdownProtocol,删除 dashboard bootstrapRequired 死分支及其 i18n、以及零引用的 RestartLifecycleFlags。关停协议常量与状态机不动。
  • 会话内定位改为先问活着的 daemon(GET /api/sessions/:id),已应答 404 即权威不存在;问不到再读库。隔离判定移到 occupancy 之前;白板解绑在 daemon 已应答任意状态码时不再回落宿主写。
  • 操作员可见性:botmux status VERSION 列、dashboard 版本卡、安装后提示,以及历史弹层 staleHint 的真判定(该 bot 的 descriptor.botmuxVersion 存在、非 0.0.0、且与磁盘版本不相等才提示重启;只比是否相等)。

扁平 store 删除见紧随其后的独立 PR(A-8),本 PR 的 sessionStore.init() 无参调用允许非 0。

影响面

共用路径:session-store / session-command-host / cli.ts 离线 close·delete·unbind、resolveSessionById(send / detectCurrent / whiteboard / dispatch / report)、dashboard 版本卡与历史弹层、descriptor 读写白名单、fleet supervisor killTimeoutMs

会话类型:话题 / 群 / adopt / restore 共用同一套 occupancy 拒绝与 daemon-first 定位;Pty 与 Tmux 的 close/abandon 仍走同一 host apply。沙盒 / origin-channel 子进程只陈述状态,不含 botmux restartowner:false worker 在无 .db 且有 leftover JSON 时 fail closed,不建库、不解析 JSON。

Test plan

bun run build
# 通过(tsc + dashboard bundle)

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 \
  test/resolve-session-by-id.test.ts test/session-store-copy.test.ts \
  test/trigger-session-root-message.test.ts \
  test/daemon-discovery.test.ts test/dashboard-registry.test.ts \
  test/shutdown-supervisor-contract.test.ts \
  test/dashboard-update-action.test.ts test/daemon-version-display.test.ts
# 16 files, 445 passed, 2 skipped

bun run test -- test/cli-send-hook-context.test.ts
# 27 passed
bun run test -- \
  test/current-turn-provenance.test.ts test/restart-report.test.ts \
  test/mojo-isolation-inventory-failclosed.test.ts \
  test/whiteboard-cli.test.ts test/session-delete-close-barrier.test.ts \
  test/scheduler-cli-scope.test.ts test/cli-list-riff.test.ts
# 58 passed

验收 grep(相对本 PR):

  • kind: 'json'|loadFromFrozenJson|abortIf|legacyHeartbeatHeldsession-store.ts / session-command-host.ts:0
  • sessions-${ctx.currentAppId}.jsonfs-policy.ts:0
  • sessions(-[^.]+)?mojo-containment-command.ts:0
  • withFileLockSyncsession-store.ts:仅导入 + 中毒恢复两处
  • sessions.get(sid)cli.ts:0
  • sessionStore.init() 无参:本 PR 允许非 0(A-8 达标)
  • sessionStoreProtocol|botmuxVersiondaemon.ts / daemon-discovery.ts / dashboard/registry.ts 均命中
  • PM2_DAEMON_KILL_TIMEOUT_MSshutdown-budgets.ts:0(已改为 FLEET_DAEMON_KILL_TIMEOUT_MS

未在本机做 switch:here / daemon 重启(worktree 施工约束)。

未决

  • A-8(删扁平 store / init(appId) 必填)另开 PR,与本 PR 同时开、后合。
  • C 项(daemon 侧租约状态机、occupancy 接 runtime、一次性导入删除等)不在范围。
  • formatDaemonVersionRestartHint 的中文与 dashboard i18n 中文模板对齐;英文走 i18n。判定函数 daemonVersionDiffersFromDisk 两边共用。

评审跟进(2026-09-09)

  • 首审 5 条全部处理于 a26249b:无 appId 枚举 daemon 时非属主 404 不再短路读库(补 3 条回归测试);occupancy 测试的已死 owner 改用 deadPid();whiteboard-cli 测试清理子进程的会话环境变量;删除零引用的 SUPERVISOR_SHUTDOWN_PROTOCOL 常量与类型。
  • 已 rebase 到 master 9387fa1。复核:bun run build 通过;20 个单测文件 499 passed / 2 skipped;4 个 spawn CLI 的文件 34 passed。
  • 本机 dogfooding:bun run switch:here && bun run daemon:restart 后的观察见后续评论。

@LucasIcarus
LucasIcarus marked this pull request as draft September 9, 2026 05:38
@LucasIcarus
LucasIcarus marked this pull request as ready for review September 9, 2026 06:18
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(首审,最终以维护者审阅为准)。整体方向与 fail-closed 语义都很扎实,bun run build 干净,绝大多数测试通过。同步到最新 origin/master 后本地复核,有 1 处多 daemon 路径的逻辑问题建议合入前修,另有 2 处测试可移植性/环境隔离问题。

1.(建议修)无 BOTMUX_LARK_APP_ID 时,单个在线 daemon 的 404 被当成「全局权威不存在」,短路了离线 bot 的库读

src/cli/resolve-session-by-id.ts:164-176:宿主 shell(没有 BOTMUX_LARK_APP_ID)会 listOnlineDaemons() 枚举所有在线 daemon 逐个问。只要其中任一个 daemon 回 404,就会置 sawAuthoritativeMiss = true,循环结束后直接返回 not_found不再读库

但每个 daemon 的 GET /api/sessions/:id 只覆盖它自己 bot 的库(dashboard-ipc-server.ts:1207)。于是这个场景会误报:

  • 目标会话属于 bot B,而 B 的 daemon 已停(行只在 B 的 SQLite 库里);
  • bot A 的 daemon 在线,A 不拥有该会话 → 回 404;
  • 解析器拿到 A 的 404 就短路,返回「未找到 session」,尽管 B 的库里明明有这行。

我写了个探针直接验证(resolveSessionById,env 不设 appId,A 在线回 404,B 离线、行 seed 在 B 的 .db):

  • 有在线 daemon 回 404 → ok=false, reason=not_found(读库被跳过);
  • 一个在线 daemon 都没有 → ok=true, source=store(库兜底正常找到同一行)。

envAppId 已设时(会话内 CLI,问的就是属主 daemon)404 权威是对的,设计文档 §3.9 也是这么定义的;问题只在枚举所有 daemon 的无 appId 分支把「某个非属主 daemon 的 404」放大成了全局结论。受影响的是宿主 shell 上带 --session-id 的定点操作(botmux send/dispatch/report/whiteboard/schedule 等)在「目标 bot 已停、别的 bot 还活着」时会从旧的「库中能找到」退化成「未找到」。

建议:sawAuthoritativeMiss 只在 envAppId 已设(即问的就是属主 daemon)时才 latch;无 envAppId 枚举时,任一 daemon 的 404 都不短路,循环后照常 readFromStore(真不存在时库读自然返回 not_found,unmigrated 判定也不受影响)。大致是:

if (asked.status === 'not_found') {
  if (envAppId) { sawAuthoritativeMiss = true; break; }
  // 无 appId:非属主 daemon 的 404 不代表别的 bot 库里没有,继续问/最后读库
}

2.(测试可移植性)session-occupancy.test.ts 用硬编码 pid: 99 充当「已死 owner」

第 371/386 行(an expired lease that is then re-claimed...)用 pid: 99 作为租约 owner,并断言「租约过期 + owner 已死 → 离线 close 应 applied」。本 PR 新增的 hostOccupancyLeaseHeldsession-store.ts:287)判定是「未过期 ownerPid 仍存活」。在 pid 99 恰好存活的机器上(本机 99 是内核线程 migration/17;pid 复用也可能撞到),该断言会失败:期望 { outcome: 'applied' },实得 { outcome: 'owned' }。master 上只看 TTL 所以一直绿。CI 容器通常是独立 pid namespace、99 不存在,因此 CI 大概率绿,但在宿主 pid namespace 的开发机上确定性变红。文件里已有现成的 deadPid()(spawn 一个立即退出的子进程取其 pid),这两处改用它即可。生产语义(owner 活着就 fail-closed)本身是对的,新测试 expired lease + live owner pid still blocks the hostprocess.pid 也正确,只是漏改了这个旧用例。

3.(测试环境隔离,nit)whiteboard-cli.test.ts spawn 子 CLI 时未清 BOTMUX_LARK_APP_ID / BOTMUX_SESSION_ID

runCli{ ...process.env } 只覆盖了 HOME/USERPROFILE/SESSION_DATA_DIR。本 PR 新增的返回行一致性校验(resolve-session-by-id.ts:59session.larkAppId !== env.BOTMUX_LARK_APP_ID 即拒绝)在「跑测试的机器本身处在一个 botmux 会话里」时会让 can bind current session explicitly 变红(seed 的是 app1,环境里的 app id 是当前会话的 bot)。把这几个 BOTMUX_* 变量在子进程 env 里显式清掉即可(CI 无这些变量所以不受影响,纯属让本机/会话内跑测也稳定)。

已核对无误的点

  • 跨进程读写收口到 SQLite、unmigrated 只看文件存在不解析 JSON、worker owner:false.db 有 leftover JSON 时 fail-closed,均符合设计;
  • 心跳探针只拒绝不带放行,四原因(lease / legacy_daemon / daemon_without_lease / store_unreadable)分流正确;readOccupancyInTxn 对 v3.18.x 无 occupancy 表返回 undefined(不会误标 store_unreadable),且探针在 openDbForOwnStore 之前读、不会给旧库白建表;
  • descriptor 新增字段在 dashboard/registry.tsdaemon-discovery.ts 都做了非字符串/空值清洗;supervisorShutdownProtocol / bootstrapRequired 死分支及 i18n 清理干净,关停协议常量保留未动;
  • 版本可见性只比「是否相等」、0.0.0 视为不可判定,不做 semver 大小比较;status VERSION 列、dashboard 版本卡、安装后提示、历史弹层 staleHint 一致;
  • readSessionRowCopiesAcrossStores 返回签名变更、loadAllSessionsSnapshot 快照、isOccupancyHeld 返回枚举等,所有调用方均已同步;
  • fleet-supervisorkillTimeoutMs 对齐到 FLEET_DAEMON_KILL_TIMEOUT_MS(重命名自 PM2_*,预算不变量断言保留)。

验证:bun run build 通过;PR 所列测试集本地 446+27+57 通过,仅上述第 2、3 条在本机环境下红(第 3 条清掉环境变量后 12/12 绿;第 2 条为 pid 可移植性)。

再次说明:以上为自动评审的初步意见,供参考,最终结论以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

补充两点(接上面的首审意见,仍为自动评审初步意见、最终以维护者为准):

4.(随 F1 一起)补「无 BOTMUX_LARK_APP_ID 枚举多 daemon」分支的回归测试

test/resolve-session-by-id.test.ts 目前 4 个用例全部带 env: { BOTMUX_LARK_APP_ID: ... },只覆盖了「问属主 daemon」这一条路;而上面 F1 的 bug 恰恰在不带 appId、枚举所有在线 daemon 的分支,该分支当前零测试覆盖。建议修 F1 时至少补两个用例锁住边界:

  • 枚举到在线 daemon 回 404、但目标行在某个离线 bot.db 里 → 应 ok: true, source: 'store'(这是 F1 的回归用例);
  • 一个在线 daemon 都没有、行在库里 → 应 ok: true, source: 'store'
  • (保留)带 BOTMUX_LARK_APP_ID 且属主 daemon 回 404 → 仍 not_found(404 权威不回退)。

否则这个「非属主 daemon 的 404 被当成全局不存在」的边界以后很容易再次被踩。

5.(nit,可顺手)SUPERVISOR_SHUTDOWN_PROTOCOL 常量与 SupervisorShutdownProtocol 类型已成死代码

本 PR 把 daemon descriptor 里的 supervisorShutdownProtocol 字段去掉后,src/core/supervisor-shutdown-protocol.ts 中的 SUPERVISOR_SHUTDOWN_PROTOCOL 常量和 SupervisorShutdownProtocol 类型在 src/test/ 都已无任何引用(全仓 grep 仅剩该文件自身的定义)。文件本身还要保留——DAEMON_GRACEFUL_EXIT_CODE 仍被 test/shutdown-supervisor-contract.test.ts 引用;可顺手把无人引用的常量和类型删掉,不删也无害。

@LucasIcarus
LucasIcarus force-pushed the feat/session-endstate-store-close branch from 6bf84e2 to a26249b Compare September 9, 2026 12:22
@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

评审 5 条已逐条处理,提交 a26249b(已 rebase 到最新 master 9387fa1 并 force-push)。

1. 无 BOTMUX_LARK_APP_ID 时非属主 daemon 的 404 短路读库 — 成立,已修。resolveSessionById 只在 env 指向属主 daemon 时把 404 记为权威不存在;枚举所有在线 daemon 的分支里,任一 404 只说明该 bot 的库里没有,循环后照常 readFromStore。文件头注释同步改写。

2. session-occupancy.test.ts 用硬编码 pid 充当已死 owner — 成立,且不止 371 行那处:ownerPid: 4242 的两条「过期租约」用例(legacy_daemon 判定、stale heartbeat 放行)同样依赖该 pid 不存活。三处都改为文件里现成的 deadPid()

3. whiteboard-cli.test.ts 子 CLI 继承会话环境变量 — 成立,已修。spawn 前显式 delete 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 而不是赋 undefined:Node 会把 undefined 传成字符串 "undefined")。

4. 补无 appId 枚举分支的回归测试 — 已补三条:非属主 daemon 回 404、目标行只在离线 bot 的 .db 里 → ok:true, source:'store'(去掉修复后该用例确实变红,已验证);无在线 daemon → 读库;枚举到的属主 daemon 回 200 优先于库。原有「带 appId 且属主 404 → not_found」保留。

5. SUPERVISOR_SHUTDOWN_PROTOCOL 常量与类型已成死代码 — 已删,DAEMON_GRACEFUL_EXIT_CODE 保留,文件头注释相应收窄。

rebase 后复核:bun run build 通过;PR 原测试集 + 本次新增,bun run test 20 个纯单测文件 499 passed / 2 skipped,4 个 spawn CLI 的文件(whiteboard-cli / session-delete-cli / scheduler-cli-scope / cli-list-riff)34 passed;设计文档「验收标准」里的 grep 全部达到期望值(init() 无参那条按约定留给 A-8)。接下来在本机做 switch:here + daemon:restart 的 dogfooding,结果另行补充。

@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

本机 dogfooding(bun run switch:here && bun run daemon:restart,a26249ba,4 个 bot + dashboard):

  • fleet 重启后 4 个 daemon 全部 online;botmux status 新增 VERSION 列,源码 checkout 下显示 0.0.0(按设计视为不可判定,不出重启提示),dashboard 行为 -
  • 4 个 descriptor 均带 sessionStoreProtocol: occupancy-v1botmuxVersion,不再有 supervisorShutdownProtocol
  • 4 个会话库的 occupancy 行分别由各自 daemon pid 持有,TTL 正常续期。
  • botmux list 正常列出 6 个活跃会话;重启期间跑在 zmx 后端的会话全部续上。
  • 会话内 botmux send(宿主路径、非沙盒)在重启前的「新 CLI + 旧 daemon」窗口与重启后都能发出:重启前旧 daemon 只回基础行,重启后新 daemon 回完整路由字段——分别覆盖了 §3.9 的降级路径与正常路径。
  • 重启前观察到的对照:旧 daemon 的 descriptor 无新字段时,botmux status 的 VERSION 列为 -,与设计一致。

未覆盖:unmigrated / legacy_daemon 的拒绝文案只有单测证据,本机没有 <3.19 的 daemon 可对照。

LucasIcarus added a commit that referenced this pull request Sep 9, 2026
- references 补 #1342 / #1344;决策记录与 §5 A-8 的「约 20 个文件」订正为 35 个文件、99 处,并注明导入快照佐证不再回落扁平 JSON 的影响
- §2.2 身份行与 §2.3 第 2 条销账(JSON 那一半随 #1342、扁平 .db 那一半随 #1344);验收 grep 的括号说明与 §6 中已删用例的表述同步
- isolated-bot-deploy.md 的 deny 清单去掉已不存在的 data/sessions.json
LucasIcarus added a commit that referenced this pull request Sep 9, 2026
- references 补 #1342 / #1344;决策记录与 §5 A-8 的「约 20 个文件」订正为 35 个文件、99 处,并注明导入快照佐证不再回落扁平 JSON 的影响
- §2.2 身份行与 §2.3 第 2 条销账(JSON 那一半随 #1342、扁平 .db 那一半随 #1344);验收 grep 的括号说明与 §6 中已删用例的表述同步
- isolated-bot-deploy.md 的 deny 清单去掉已不存在的 data/sessions.json
@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

dogfooding 追加发现并已修(e161e889):宿主 shell 跑 botmux list 每次都打「会话库尚未迁移到 SQLite,请重启 daemon」,但重启不会消失——本机 data/ 下有 4 份早已移出 bots.json 的 bot 留下的 sessions-<appId>.json,没有任何 daemon 会再导入它们。

修法:unmigrated 只对仍然存在的 bot 判定——bots.json 里配置的、当前有 descriptor 在线的、本进程所属的 appId 三者并集(新增 services/known-bot-app-ids.ts,每个来源 best-effort,沙盒读不到 bots.json 视为无)。loadAllSessionsSnapshot 增加 knownAppIds 选项;cli.ts loadSessionsresolveSessionById 的库兜底、dashboard 删板解绑三处传入。被移出 bots.json 的残留 JSON 是废弃数据,不再触发提示;仍配置但没启动过 SQLite 版本的 bot 照常提示。补了 4 条测试,设计文档 §3.3 补了这条规则。本机复核:宿主 shell botmux list 不再出现提示,6 个活跃会话正常列出。

LucasIcarus and others added 10 commits September 11, 2026 17:19
- 基线切到 origin/master@0aba0fdd(含 Stage 3 #1308),版本时间线补齐 v3.18.0 / v3.18.12 / v3.19.0 三个分界
- 升级策略:不再等 fleet 自动重启,也不设复核日期;升级后用户手动 botmux restart,重启前新代码遇到旧 daemon 时明确失败并提示,据此把跨进程 JSON 读写、JSON 离线写、沙盒 JSON 授权、worker 的冻结 JSON 回落全部列为可删
- 心跳探针经复核不是兼容路径:displaced / claim 失败的 daemon 照常写行、supervisor 8s 强杀不释放租约,删掉它会开丢更新窗口。改为只拒绝、带原因(旧 daemon / 未持租 / 库不可读),删掉发布前的第二次判定
- descriptor 加 presence-based 能力位与仅用于文案的版本号,不做版本比较;文案按操作员 shell 与会话子进程分流,隔离判定前移
- 新增 daemon 侧「持有租约才写」的不变量、supervisor killTimeout 与关停预算对齐、宿主侧租约有效性两段规则
- 差距表补齐 owner 侧 138 处 updateSession、remote lineage 第二套事务、serializeByAnchor、bridgeMarkerCleanupFences、gate 的用户可见拒绝等未覆盖项,以及与升级窗口无关的现存缺陷
- §5 按 PR 粒度拆成收尾 PR / 前置 PR / 单独排期;§6 逐条列出可直接删除、改写为终态断言、只改名的测试用例与夹具缺陷
- 扁平 legacy store 只存在于 2026-03-11 至 03-22(首次发布到 per-bot 拆分),早于第一个发布 tag v2.16.1,没有任何发布版本写过它:无 larkAppId 的行直接放弃,扁平 store 支持点并入收尾 PR(A-8)
- 只读库 / loadFailure 的 daemon 从降级运行改为有界重试后 fail-fast,写入 §3.7
- 一次性导入与中毒库恢复的删除条件定为 latest ≥ v3.19 满 90 天且线上 *.tmp* 孤儿核查为零(C-16),§5 C 重新编号
- 新增「阅读指引」:按要做的事读哪些节、已拍板的决策记录、收尾 PR 与前置 PR 的 grep 级验收标准与行为验收、施工约束
- 新增 §3.9:botmux send 与会话内定位底座改用 resolveSessionById(daemon 的 GET /api/sessions/:id 优先,已应答的 404 即权威,连接失败或读不到 secret 才读库),返回行须与 env 的 larkAppId 一致;daemon 侧补全每轮回复锚点与 codex 派发台账
- §3.2 中「旧 daemon 窗口里会话内 agent 回不了消息」的代价改为「降级但可达」,仅 macOS 凭证隔离 CLI 与 v2.x daemon 仍退回 unmigrated 提示
- §5 收尾 PR 增加 A-9;§3.8 常态可见性从单独排期提前为前置 PR B-11;C 项重新编号
- 阅读指引补两条决策记录、一条 grep 验收与一条行为验收(daemon 应答时 send 必须发出)
- §2.2 删除「没有命令边界就没有可入队单位」的根因表述:runSessionTurn 入队的是闭包,Stage 3 止于开场窗口是因为没有更多有复现的交错
- Stage 2 结束条件改为「session-store 里手写的行级事务只剩一个原语」,lineage 三函数与宿主路径共用同一个事务原语,不强求进命令联合;非 owner 只能施加 HostSessionCommand 这条边界已由 tsc 成立
- 删除 C-15「owner 侧 updateSession 命令化」,并在「不做」里写明理由;C 项重新编号
- Stage 3 残留里 admit / promote 改为复用 closeSession 的副本形态,不新增命令类型
- 决策记录与历史补 2026-09-09 这条
升级窗口改为手动 botmux restart 后,跨进程读写只认 SQLite,心跳只拒绝并带原因;descriptor 广告能力位与版本号,操作员面按是否相等提示重启。扁平 store 删除另开独立 PR。

Co-authored-by: Cursor <cursoragent@cursor.com>
session-command-host 新增命名导入后,failclosed 测试的全量 vi.mock 会在 bun link 阶段直接失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
- resolveSessionById:只有 BOTMUX_LARK_APP_ID 指向的属主 daemon 的 404 才是权威不存在;宿主 shell 无 appId 枚举所有在线 daemon 时,任一 daemon 的 404 只说明该 bot 的库里没有,循环后照常读库(目标 bot 已停、别的 bot 在线的场景此前会误报「未找到 session」)
- 补三条无 appId 分支的回归测试:非属主 404 后仍从离线 bot 的 .db 读到、无在线 daemon 时读库、枚举到的属主 daemon 200 优先于库
- session-occupancy 测试里三处「已死 owner」改用 deadPid()(原 pid 99 / 4242 在宿主 pid namespace 上可能存活,会让「过期 + owner 已死 ⇒ 放行」的断言变红)
- whiteboard-cli 测试 spawn 子 CLI 时显式删除 BOTMUX_SESSION_ID / BOTMUX_LARK_APP_ID 等会话环境变量(在 botmux 会话内跑测试时不再误触 larkAppId 一致性校验)
- 删除已无引用的 SUPERVISOR_SHUTDOWN_PROTOCOL 常量与 SupervisorShutdownProtocol 类型,DAEMON_GRACEFUL_EXIT_CODE 保留
dogfooding 发现:本机 data/ 下有 4 份早已移出 bots.json 的 bot 留下的 sessions-<appId>.json,没有任何 daemon 会再导入它们,但 botmux list 每次都提示「会话库尚未迁移到 SQLite,请重启 daemon」,重启也不会消失。

- 新增 services/known-bot-app-ids.ts:bots.json 里配置的 + 当前有 descriptor 在线的 + 本进程所属的 appId,三者并集;每个来源都是 best-effort(沙盒读不到 bots.json 视为无)
- loadAllSessionsSnapshot 增加 knownAppIds 选项,只把已知 bot 的残留 JSON 报为 unmigrated(fallbackAppId 始终视为已知)
- cli.ts loadSessions、resolveSessionById 的库兜底、dashboard 删板解绑三处传入已知 bot 集合
- 测试:known-bot-app-ids 两条;snapshot 与 resolveSessionById 各补一条「已移除 bot 的残留不算 unmigrated、仍配置的算」
- 设计文档 §3.3 补这条判定规则
master 的 Codex 实例配置守卫仍按旧 resolveStoreFile.kind 读 JSON;rebase 后该字段已不存在,改为只读 .db,unmigrated 时 fail closed。

Co-authored-by: Cursor <cursoragent@cursor.com>
@LucasIcarus
LucasIcarus force-pushed the feat/session-endstate-store-close branch from e161e88 to ee25027 Compare September 11, 2026 09:22
LucasIcarus added a commit that referenced this pull request Sep 11, 2026
- references 补 #1342 / #1344;决策记录与 §5 A-8 的「约 20 个文件」订正为 35 个文件、99 处,并注明导入快照佐证不再回落扁平 JSON 的影响
- §2.2 身份行与 §2.3 第 2 条销账(JSON 那一半随 #1342、扁平 .db 那一半随 #1344);验收 grep 的括号说明与 §6 中已删用例的表述同步
- isolated-bot-deploy.md 的 deny 清单去掉已不存在的 data/sessions.json
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审的初步意见(针对新 commit 的增量意见,以维护者审阅为准)。

先说结论:ee25027 这个新 commit 本身是必要且正确的,改法我认可;但 PR 当前 head 的 CI 是红的(testtest (2/3)test (3/3)),红因不在这个新 commit,而是本 PR 与主干 #1333 的一处语义碰撞,需要作者补一处测试改动。

一、新 commit 解决的问题(已核实)

主干 #1333(Codex 会话级实例绑定)新增了 readBotSessionsStrict(),它按旧的 StoreFileRef 形状写:读 ref.kind 分流 json/sqlite、且假定 resolveStoreFile() 一定返回值。本 PR 恰好把这两点都改了(kind 字段删除、resolveStoreFile() 改为可能返回 undefined)。这是 git 报不出冲突的语义冲突——文本不重叠,但 rebase 完就编译不过。

实证:在作者上一个 head 9dc7c78 上跑 tsc --noEmit 是 exit 2、5 条错误(ref possibly undefined ×4、Property 'kind' does not exist on type 'StoreFileRef');ee25027 之后 tsc 0 错误。

改法本身也核过,三点都对:

  1. readStoreEntries(ref) 统一读原语,不再自己分流;
  2. 保留 [undefined, appId] 两个 store 的遍历——扁平 store 的删除按设计文档是留给独立的 A-8 PR 的,这里保留是对的;
  3. 新增 seen 去重集,避免同一行在扁平 store 和 per-bot store 里被算两次(设计文档里记为真 bug),比主干原版更严谨;开头补 unmigrated fail-closed 抛错,与 session-store.ts 既有的 worker 侧 fail-closed 语义一致。

二、CI 红的根因(建议本 PR 内一并修)

11 个失败全在 test/cost-calculator.test.ts(10) + test/cost-calculator-pricing.test.ts(1),报错一致:

SessionStoreUnavailableError: session store is unavailable: EBADF: bad file descriptor, write
  getSession (src/services/session-store.ts:1687)
  ← resolveSessionTranscriptPath (transcript-resolver.ts:295)
  ← readSessionUsage ← getSessionTokenUsage

这同样是本 PR × #1333 的碰撞,两边单独都无害:

  • feat(codex): 支持会话级实例加权选择与固定绑定 #1333 那一侧:transcript-resolver.ts 的 codex 分支新增了一次 getSession() 调用(用来查会话级实例绑定)。51d671347^ 上这个调用不存在。
  • 本 PR 这一侧:getSession() 加了 if (loadFailure) throw(按设计文档 §5 M 项,本来就该做)。

而这两个测试文件用 vi.mock('node:fs') 造了一套合成文件系统:existsSync 在多数用例里被整体 mock 成 true,于是 <db>.tmp / -journal / -wal / -shm 四个 import sidecar 全"存在",会话库被判定为中毒库,走进 load() 的恢复分支拿文件锁;openSync 发的是从 10000 开始的合成 fd,但 mock 没有覆盖 writeFileSyncwithFileLockSync 里真实的 writeFileSync(fd, …) 拿到这个假 fd 就 EBADF

以前 getSession() 把失败咽掉,所以这套矛盾的 mock 一直躺着没被发现;本 PR 让它 fail closed,就浮出来了。

四格交叉验证(都在本机实跑):

代码基线 getSession 抛错 结果
本 PR head 有(现状) 11 红
本 PR head 临时去掉 65 全绿
干净 master ba70056 临时加上 11 红(同形)
#1333 之前 51d671347^ 临时加上 65 全绿

也就是说:这个红是主干上已经存在的隐性缺陷(矛盾的测试 mock),只是必须等本 PR 的 fail-closed 落地才会显形;不是 ee25027 引入的,也不是 flake。

另外确认了不是生产缺陷:那条"中毒库"路径要求 <db>.tmp* sidecar 真实存在,真实文件系统上不会凭空成立;生产侧三个 getSessionTokenUsage() 调用点(usage-ledger 的 3 处、session-store 关闭快照、dashboard-rows)里,前两类都在 try/catch 内,抛错只会降级为日志。所以这是测试侧的问题,不建议改 src/(把 fail-closed 改回去会丢掉本 PR 的设计目标)。

三、建议改法(已在本地验证通过)

这两个套件是 cost-calculator 的单元测试,其它协作者(codex-transcript / traex-transcript / pi-transcript / aiden-checkpoints / registry…)都已经 mock 掉了,唯独漏了会话库。补一个最小 stub 即可,两个文件各加一段:

// resolveSessionTranscriptPath() 的 codex 分支会查会话库里的实例绑定。
// 本套件整体 mock 了 node:fs,真实 store 会读到合成文件系统;stub 掉它需要的这一个入口。
vi.mock('../src/services/session-store.js', () => ({
  getSession: vi.fn(() => undefined),
}));

验证结果:

  • cost-calculator.test.ts + cost-calculator-pricing.test.ts65/65 绿(改前 11 红);
  • 不会因此丢覆盖:feat(codex): 支持会话级实例加权选择与固定绑定 #1333 那条绑定分支的真实覆盖在 session-cli-instances-acceptance.test.ts:405-411transcript-resolver-bot-home.test.ts,这两个套件用的是真实 session store、没有 mock,跑 session-cli-instances-acceptance + transcript-resolver-bot-home + insight-report86/86 绿

(若更倾向于修 mock 本身,另一条路是给 node:fs mock 补一个 fd 感知的 writeFileSync;我试过,EBADF 会消失但接着撞上"中毒库无法恢复"的下一层报错,还得再让 existsSync 对会话库文件返回 false——比上面的 stub 绕,所以推荐前者。)

四、其余部分

除上述一处外,ee25027 未引入新问题;此前 F1–F5 的修复在本轮 rebase 后仍然在位(逐个比对 patch-id 确认没有被改写或丢失)。我在最新 origin/master(ba70056) 上做了本地 rebase 验证(10 个 commit、0 冲突,未推送、未改动作者分支),rebase 后 tsc --noEmit 0 错误、bun run build 通过、会话库相关 8 个测试文件 207 用例全绿。

补上第三节那两段 stub 后 CI 应当转绿,届时我再复验一次。

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.

2 participants