Skip to content

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

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

LucasIcarus wants to merge 14 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
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 应当转绿,届时我再复验一次。

@LucasIcarus
LucasIcarus force-pushed the feat/session-endstate-store-close branch from ee25027 to 1ff23c0 Compare September 14, 2026 08:17
LucasIcarus added a commit that referenced this pull request Sep 14, 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

已 rebase 到最新 origin/master(d2b32571),并处理了上一条自动评审意见(cost-calculator 套件与 #1333 的 mock 碰撞)。

rebase

  • 10 个原 commit 逐个重放;仅 src/services/session-store.tstest/session-store.test.ts 有文本冲突(主干 feat(worktree): 完善话题工作区生命周期 #956readStoreActiveRows 加的 strict 参数 / 新增的 findActiveSessionsByWorkingDirStrict,撞上本 PR 删 JSON 读路径),按本 PR 协议解决:readStoreActiveRows 只保留 SQLite 分支,import 合并。
  • 逐个核对了主干 19 个 commit 与本 PR 的语义碰撞面(不只看 git 冲突),除下面两处外没有碰撞。

新增 4 个 commit

  1. d3abfbe6 fix(session):feat(worktree): 完善话题工作区生命周期 #956 的 worktree 回收严格清单在本 PR 协议下会把「另一 bot 的 sessions-<appId>.json 仍在、.db 未建」静默当成空 store——升级窗口内未重启 bot 在该 worktree 上的活动会话会漏出删除清单。改为:有待迁移 store 时按仍存在于本机的 bot 判定并 fail closed;「哪些 bot 存在」的判定在这里必须是结论性的(knownBotAppIds / listOnlineDaemons 新增 strict 模式,bots.json 读不到、不是 JSON、没有 bot 列表,或 descriptor 目录列不出时直接抛错,不把「无法判定」折成「废弃」)。主干那条「legacy JSON 坏行 fail closed」用例(JSON 已不再被读)改写为 4 条新用例。
  2. e374f6fe fix(test):按评审建议给两个 cost-calculator 套件 stub 掉 session-store(CI 红的根因)。
  3. 04846452 docs:设计文档 §3.3 / §3.8 记录上述两条裁定。
  4. 1ff23c04 fix(dashboard):编译版二进制里 /api/update/statuscurrent 是 dashboard 进程自身烘焙的版本而非磁盘版本(install-info.ts 记录过的 baked 遮蔽),「运行中 daemon vs 磁盘」提示在整机升级未重启时不会出现、部分进程重生后还会反向。改为编译态按无法判定处理(不出提示;历史弹层 staleHint 改用新增的 diskVersion 字段判定),Node 安装形态行为不变;在 binary-self-update.test.ts 的 baked 遮蔽规则旁加了 source guard。

验证

  • tsc --noEmit 0 错误;bun run build 通过(含 dashboard bundle)。
  • bun run test(vitest unit 全量):1313 文件 / 22866 用例通过。2 个失败均为本机 macOS 环境问题,在干净 origin/master 上同样失败:daemon-rename-route/tw 用例(fixture 在 /var/folders、代码 realpath 成 /private/varfeat(worktree): 完善话题工作区生命周期 #956 主干侧),以及一条 tmux 用例在并行负载下的 flake(单独运行通过)。
  • bun run test:bun:1141/1148 文件通过;7 个失败文件与本 PR 无关,已在干净 master 的临时 worktree 上逐一复现(bun 在 macOS 写 ~/Library/Caches/bun/proc、pm2 socket、tmux 登录 shell 等环境因素)。
  • 相关单测文件(session-store 家族、known-bot-app-ids、daemon-discovery、command-handler、cost-calculator×2、binary-self-update、whiteboard 等)单独运行全绿;bun 可跑的 known-bot-app-ids / daemon-discovery / binary-self-update 三个文件在 bun test 下也通过。

留作后续(不在本 PR 改)

  • feat(worktree): 完善话题工作区生命周期 #956 删除锁内的最终清单没有自己的错误边界,抛错只走 handler 级 catch 记日志(主干既有;两次清单之间状态未变时不会走到那里)。
  • /tw 用例在 macOS 的路径可移植性(主干侧)。
  • dashboard 版本卡的 runningDaemonRestartHint 仍是服务端中文文案、未走 i18n(PR 描述「未决」已列)。

叠在本 PR 之上的 #1344 已同步 rebase,接下来从该分支做本机 dogfooding。

LucasIcarus and others added 12 commits September 16, 2026 20:16
- 基线切到 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>
主干 #956 新增的 findActiveSessionsByWorkingDirStrict 按旧协议把 legacy JSON
当作可读 store(解析行、坏行 fail closed)。本 PR 收口后跨进程读取面只认
.db、JSON 一律不解析,于是「另一个 bot 的 sessions-<appId>.json 仍在、.db
未建」在原实现里会被静默当成空 store——升级窗口内未重启的 bot 若在该
worktree 上仍有活动会话,删除 worktree 的安全清单会漏掉它。

改为:清单前先按 listUnmigratedAppIds 找出待迁移的 store;有待迁移时再按
仍存在于本机(bots.json / 在线 descriptor / 本进程 appId)的 bot 过滤,任一
命中即抛 SessionStoreUnmigratedError。「哪些 bot 仍然存在」在这里必须是结
论性的:knownBotAppIds 新增 strict 模式,bots.json 读不到 / 不是 JSON / 没有
bot 列表、或 descriptor 目录列不出时直接抛错(listOnlineDaemons 同步加
strict),不把「无法判定」折成「废弃数据」。已移出 bots.json 的残留 JSON
与本 PR 的既有裁定一致,视为废弃不阻塞。command-handler 的重试任务与
/close 的兄弟会话清单两处调用已有 try/catch,抛错会转成「无法完整读取同
worktree 会话清单,已取消删除」;删除锁内的最终清单沿用主干既有的 handler
级 catch(只记日志),两次清单之间状态未变时不会走到那里。

rebase 时删除了主干那条「legacy JSON 坏行 fail closed」用例(JSON 已不
再被读取),以「已知 bot 未迁移 fail closed / 未知 bot 残留忽略 / bots.json
不可读 fail closed / 无待迁移时不读 bots.json」四条用例替代。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
主干 #1333 让 transcript-resolver 的 codex 分支多调一次 getSession(),而本
PR 把 getSession() 改成 loadFailure 时 fail closed。这两个套件整体 mock 了
node:fs(existsSync 多数用例整体返回 true),真实 store 会把 .tmp/-wal
等 sidecar 判成中毒库并走恢复路径,withFileLockSync 拿着合成 fd 调真实
writeFileSync 报 EBADF——以前 getSession 咽掉失败所以一直没暴露。

这是单元测试侧的矛盾 mock,不是生产缺陷:真实文件系统上 sidecar 不会凭
空存在;这条链只在 daemon 进程里跑,usage-ledger 三处与关闭快照各自在
try/catch 内,dashboard-rows 则在 IPC 路由的统一错误边界内。与套件里其
它协作者一致,stub 掉 session-store 唯一被用到的入口;#1333 绑定分支的
真实覆盖仍在 session-cli-instances-acceptance(真实 store、无 mock)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LucasIcarus and others added 2 commits September 16, 2026 20:16
§3.3 原文只写了常规跨进程读「不把 unmigrated 的 bot 计入结果并提示」。
主干 #956 新增的 worktree 回收是破坏性路径,按同一规则跳过会漏掉未重启
bot 在该目录上的活动会话,故裁定为 fail closed,且「哪些 bot 仍然存在」
的判定在此处必须是结论性的(bots.json / descriptor 目录读不到即抛错)。

§3.8 补记:编译版二进制里 dashboard 进程读到的 current 是自身烘焙版本而
非磁盘版本,「运行中 daemon vs 磁盘」在 dashboard 侧按无法判定处理。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/api/update/status 的 current 来自 currentInstalledVersion() →
botmuxVersionAt(),编译版二进制里它返回的是 dashboard 进程自身烘焙的版本
(install-info.ts 说明过 baked 会遮蔽磁盘),不是 install.sh 刚放到磁盘上
的版本:整机升级未重启时两边同为旧版本,提示不出现;部分进程重生后甚至
会反向提示「daemon 新、磁盘旧」。

改为:编译态不给 formatRunningDaemonsRestartSummary 磁盘版本(不出提示),
并在响应里单独给出 diskVersion(仅 Node 安装形态有值);会话历史弹层的
staleHint 改用 diskVersion 判定,编译态同样保持沉默而不是反向。编译态的
重启提示由 install.sh 安装后输出与 botmux status 的 VERSION 列承担。
source guard 加在 binary-self-update.test.ts 的 baked 遮蔽规则旁边。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@LucasIcarus
LucasIcarus force-pushed the feat/session-endstate-store-close branch from 1ff23c0 to 4aefaee Compare September 16, 2026 12:17
LucasIcarus added a commit that referenced this pull request Sep 16, 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
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