Skip to content

refactor(session): 会话行命令统一 apply,CLI 与白板离线写改走同一实现(Stage 2) - #1280

Merged
deepcoldy merged 6 commits into
masterfrom
feat/session-single-apply
Sep 7, 2026
Merged

refactor(session): 会话行命令统一 apply,CLI 与白板离线写改走同一实现(Stage 2)#1280
deepcoldy merged 6 commits into
masterfrom
feat/session-single-apply

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

改了什么

落地 docs/design/2026-08-12-session-restage-store-first.md §3 的 Stage 2「单一 apply 路径」(依赖已合入的 #1202 Stage 1 occupancy):

  • 新增 src/services/session-commands.tsclose / prune / whiteboard / worker-exited 四条命令对会话行的唯一变换 applySessionRowCommand(row, command, { now })。纯函数、不做 I/O;结果是 applied / noop / refused(reason)。对已关闭行再 close 是 noop,不再刷新 closedAt
  • daemon 侧改用它session-store.closeSession 只保留锁外的 token 快照采样,字段变换交给共享 apply;/api/sessions/:id/whiteboard 路由同样调用它(响应体不变)。
  • 宿主侧改用它session-store.mutateSessionRowOffline(target, 闭包) 删除,替换为命令形态的 applySessionCommandUnowned / readSessionRowUnowned(同一 BEGIN IMMEDIATE / 文件锁事务、同一租约 + 心跳判定)。services/session-offline-write.ts 改名 services/session-command-host.tsapplySessionCommandAsHost / readSessionRowAsHost / isOccupancyHeld)。CLI 的 delete / list 自动 prune / whiteboard 与 dashboard 删板解绑都走它;CLI 私有的三份 close 字段清单删除。
  • 结果集显式化owned / missing / contended 三种「不是本进程能动的」情形分开报告,不再用 undefined 混同;CLI 相应给出 session_row_missing / session_store_busy
  • daemon 专属的 close 输入tokenUsageparkMojoLineageparkLocalResidualclearRiffParentTaskIdclearMojoCloseJournal)在 HostSessionCommand 上类型化为 never:宿主构造不出能抹掉 mojo 对账栅栏或钉死 token 快照的命令(tsc 可检查,test/session-commands.test.ts@ts-expect-error 钉住)。
  • 沙盒 / 读隔离 / credential-only CLI 明确失败:新增 core/managed-origin-capability.ts#isIsolatedCliProcess(沙盒 outbox env / 宿主 read-isolation env / worker 打在隔离子进程上的 origin channel / 探针 inode 上的内核拒绝,用「读不到 secret」——从未跑过 daemon 的机器上宿主 shell 也读不到)。BOTMUX_ORIGIN_CHANNEL_ID 只由 worker 注入会话 CLI(含 credential-only Seatbelt/bwrap),device 登记不会把它写进 host shell。credential-only 只掩 device-authBOTMUX_HOME 仍可写,fail-closed 是阻断 confused-deputy(被注入的 agent 不能在 daemon 挂掉时离线改会话行),不是「反正写不了」。绑定白板失败时不再把 id 写进内存假装成功。

为什么

#1202 之后 apply 仍有两套:daemon 走 updateSession / persistRow,其它进程走 mutateSessionRowWhenUnowned + 各自手写的字段闭包(CLI 离线 close、CLI 离线 prune、白板解绑各一份,且字段集与 daemon 的 close 并不一致)。设计文档 §0 原则 2 要求「occupancy / apply / turn 走同一条命令路径,CLI 与 daemon 共用」,原则 3 要求边界结构化。本 PR 把行级变换收成一份,并把「任意闭包改行」这个入口从模块导出里拿掉。

按设计文档,临时 host 的激活就是那一个排他事务:事务内读 occupancy 判权威、读新鲜行、apply、发布,不写租约行——同一事务内的 claim + release 对其它连接不可观测;跨多步 abandon 持有租约只会让期间启动的 daemon 被判 displaced 到下一个心跳 tick。多步 abandon 的每一步仍在各自事务内重验权威(与改前一致)。文档已按此更新,Stage 2 标为已落地。

有意保留的行为差异(都写进了设计文档 Stage 2 第 6 条)

  • 宿主 close 不写 tokenUsage:宿主 shell 未必能解析 BOT_HOME 下的 transcript(解析器依赖 SESSION_DATA_DIR 环境变量),落一个永久 null 会让 dashboard 对该已关闭会话停止实时计算。daemon 侧采样与写入逻辑逐字保留。
  • 宿主 close 不抹 mojoCloseJournal(与改前离线 close 一致);daemon 的 store close 仍在自身 prepare 之后抹除。
  • 以前离线 close 额外 deletecodexAppDispatchLedger / codexAppGenerationCommits / queuedActivation* / pendingRepoSetup,现在与 daemon 一致地保留在已关闭行上(daemon 从未删过;resume 时 reactivateClosedSession 清)。
  • 宿主 close 现在也清 dashboardAttachments / queuedAttachments,并在 commit 后用与 daemon 同一个 cleanupMaterializedDashboardImages 删掉 materialized 目录(以前离线 close 留着字段、目录永远无人清)。

影响面

  • 共用路径services/session-storecloseSession 内部、离线事务原语)、services/session-command-host(原 offline-write)、core/dashboard-ipc-server 白板路由、cli.ts delete / list prune / whiteboard、services/whiteboard-store 删板解绑、core/managed-origin-capability 新增分类函数。
  • 会话类型:话题 / 群 / adopt / restore 走同一套 store 导出;adopt 行的「杀 worker 不碰用户 pane」与 expectAdopted 前置条件保留。Pty / Tmux / zmx / herdr 的 backing 拆除代码未动。riff / mojo:宿主 close 不再可能抹 journal,daemon 路径不变。
  • 进程形态:daemon(in-memory 行)、宿主 CLI、dashboard 进程(删板)、沙盒 / 读隔离 CLI(只发命令)。worker owner: false 不受影响(不走这些入口)。
  • 升级窗口:JSON 读 / 心跳回落两条兼容路径原样保留,删除条件不变(Stage 0 / Stage 1)。
  • 编译态:无 __dirname / 路径拼接。

测试验证

  • bun run build 通过;tsc --noEmit 干净。
  • 新增 test/session-commands.test.ts(11 条:各命令的字段效果、幂等 noop、拒绝原因、类型边界)。
  • 改写 test/session-occupancy.test.tstest/session-store.test.tstest/session-store-sqlite.test.ts 到命令 API:断言的不变量不变(新鲜行而非快照、abortIf 入口 + 发布前双探测、SQLITE_BUSYcontended、绝不创建空库、JSON 升级窗口),新增 readSessionRowUnowned 同门槛、expectAdopted 拒绝、JSON 文件锁竞争报 contended
  • test/session-delete-cli.test.ts 新增:宿主离线 close 不写 tokenUsageBOTMUX_SEND_RELAY 沙盒且无 daemon 时 fail closed; BOTMUX_ORIGIN_CHANNEL_ID 且无 daemon 时同样 fail closed(钉住 origin-channel arm)。test/managed-origin-capability.test.ts 覆盖四条正向信号。test/session-commands.test.ts 覆盖已关闭行残留 queuedAttachments / dashboardAttachmentstest/session-occupancy.test.ts 验证宿主 close 真的删掉 materialized 图片目录。
  • 跑过:bunx vitest run --project unit test/session-commands.test.ts test/session-occupancy.test.ts test/session-store.test.ts test/session-store-sqlite.test.ts test/session-delete-cli.test.ts test/whiteboard-unbind-session.test.ts test/ipc-whiteboard-route.test.ts test/mojo-isolation-inventory-failclosed.test.ts test/daemon-discovery.test.ts → 9 文件全过。
  • 全量 bun run test:本机失败与本 PR 无关——多数是 bun 1.4.0 对钉死的 1.4.1 的版本断言(session-store-sqlite-bun-import / session-store-sqlite-poisoned-recovery 等,master 同样红),外加全量负载下的偶发超时(单跑通过)。不是 21 条业务回归。
  • 编译产物 dist/cli.js 对临时 SESSION_DATA_DIR 实测:离线 delete 开放行 → closedpreviewTarget 清除、不写 tokenUsageBOTMUX_SEND_RELAY 下无 daemon → 报「隔离会话内不能离线修改会话」、行保持 active
  • 本地 dogfooding 见下。

Dogfooding

bun run switch:here && bun run daemon:restart(2026-09-06 14:20 UTC,supervisor 下 4 个 bot + dashboard):

  • 4 个 bot 的 session-stores/<appId>/sessions.db 都由新 pid 持有 occupancy 租约(重启后 79s 内续期正常)。
  • 当前 bot Restored 5 session(s),两个 worker 从 journal 恢复中断轮次;重启后各 bot 的 daemon-*-err.log / daemon-*-out.log 无 ERROR / TypeError / 模块缺失。
  • 发起本 PR 的会话本身跑在被重启的 daemon 之下,重启后 botmux send / botmux whiteboard current / botmux status 走新 build 正常。
  • 白板功能在本机未开启,/whiteboard 路由的 daemon 侧改动由 test/ipc-whiteboard-route.test.ts(6 条,含 409 CAS)覆盖,未做 live 验证。
  • 沙盒 / 读隔离 CLI 的 fail-closed 路径由 test/session-delete-cli.test.ts 与上面编译产物实测覆盖,未在真实沙盒会话里点过 botmux delete

@LucasIcarus
LucasIcarus force-pushed the feat/session-single-apply branch from 0060dfa to c28ca67 Compare September 7, 2026 03:52
@LucasIcarus
LucasIcarus marked this pull request as ready for review September 7, 2026 05:14
LucasIcarus and others added 2 commits September 7, 2026 13:19
落地设计文档 §3 Stage 2「单一 apply 路径」:

- 新增 services/session-commands.ts:close / prune / whiteboard / worker-exited
  四条命令对会话行的唯一变换,纯函数、无 I/O;已关闭行再 close 为 noop
- daemon 的 session-store.closeSession 与 /whiteboard IPC 路由改用共享 apply;
  token 快照仍在锁外采样后作为命令字段传入
- 删除 mutateSessionRowOffline 的任意闭包改行入口,替换为命令形态的
  applySessionCommandUnowned / readSessionRowUnowned;结果集显式区分
  applied / noop / refused / owned / missing / contended
- services/session-offline-write.ts 改名 session-command-host.ts;CLI 的
  delete / list 自动 prune / whiteboard 与 dashboard 删板解绑都走它,
  CLI 私有的三份 close 字段清单删除
- daemon 专属 close 输入在 HostSessionCommand 上类型化为 never,并以
  类型断言在构建 tsc 中钉住边界
- 沙盒 / 读隔离 CLI 在 daemon 不可达时明确失败,不再尝试离线写;判定用
  正向隔离信号,不用「读不到 IPC secret」
- 设计文档 Stage 2 标为已落地,写明临时 host 的激活即一次排他事务、不写租约行

Claude-Session: https://claude.ai/code/session_01EuESAEsSPtxRv67D4WRnMk
并发二次 close 输掉 status 竞态时,纯 noop 会丢掉 mojo residual / lineage。
宿主无 daemon 字段的再 close 仍是 noop;daemon 专属 park / journal wipe
按字段是否变化落地。closeSession 对 noop 不再重写行,也不再扫 transcript。

Co-authored-by: Cursor <cursoragent@cursor.com>
BOTMUX_ORIGIN_CHANNEL_ID 只盖在 worker 拉起的隔离子进程上,host shell 即使机子做过 device 登记也不会带它;credential-only 子进程同样写不了 ~/.botmux,daemon 不可达时 fail-closed 是有意的。白板绑定失败不再把 id 写进内存。补上已关闭行残留附件与宿主删目录的测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

预先意见处理:

F1 成立一半。 BOTMUX_ORIGIN_CHANNEL_ID 这条原先确实没有测试,删掉 25 条仍绿是真的。但「已登记机器上用户的 host botmux delete 会关不掉」不成立:这个变量只由 worker 打进会话 CLI 子进程(含 credential-only),device 登记不会写进 host shell。本机 fleet 58 个 CLI 都没有它,和「没登记」无关,host 路径本来就不会带。credential-only 子进程本身也写不了 ~/.botmux,daemon 挂了时 fail-closed 是有意收紧(明确报错,而不是在只读/禁写树上砸 SQLite),不是误伤 host。已补 isIsolatedCliProcess 四条信号 + delete CLI 只带 origin channel 的用例。

F2 成立,顺手修了。 改前就丢返回值;本 PR 多了 forbidden_isolated 这条 false。三个调用点改为 bindSessionWhiteboard:落盘失败不再把 id 写进内存,并 stderr 说明白板已创建但没绑上会话。

F3 成立,已补测。 已关闭行再 close 仍清 queuedAttachments / 残留 dashboardAttachments,且不刷新 closedAt

F4 成立,已补测。 applySessionCommandAsHost 离线 close 后 materialized 目录确实被删。

全量失败口径:不是 21 条业务回归。本机红的是 bun 1.4.0 vs 钉死的 1.4.1 版本断言,外加全量负载偶发超时。

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审核对意见(第二轮):

整体结论:0 阻断,建议合入前确认 F1 一处

F1(建议确认):isIsolatedCliProcessBOTMUX_ORIGIN_CHANNEL_ID 当隔离信号

链路核实无误:

  • worker.tsmanagedOriginChannelRequired(含 credentialOnlySeatbelt || credentialOnlyBwrap)时注入 BOTMUX_ORIGIN_CHANNEL_ID;该变量在 BOTMUX_INJECTED_ENV_KEYS 里,会进 tmux pane。
  • credentialIsolationRequired = markerExists || deviceCredentialExists:一旦登记过 device 凭据,所有本地会话(含未开 sandbox 的普通 Linux 会话)都走 credential-only bwrap,都带这个章。
  • 因此 daemon 不可达时,pane 内进程跑 botmux delete / list 自动 prune / whiteboard 会从「离线兜底写盘」变成报「隔离会话内不能离线修改会话」。

需要确认的一点:credential-only bwrap 下会话库不是只读的——buildCredentialOnlySandboxArgs--bind / /(可写)+ 只 mask device-auth/ 与根级权威文件,data/session-stores/ 完全可写。所以这里的 fail-closed 不是「把本来就会失败的写换成干净报错」,而是主动阻断一条以前能走通的官方路径(agent 仍可绕过 botmux 直接写库,所以这是防 confused-deputy,不是防直接篡改)。

这个行为变更本身是合理的硬化(防 prompt-injected agent 离线改会话行;用户在 host shell 跑 botmux 不受影响,daemon 在时 IPC 也不受影响),但设计文档 §1 写的「跑在 bwrap / Seatbelt 里的进程对会话库只有 readOnly 授权」对 credential-only 不成立,建议修正措辞,避免后续读者误判边界。另外 ORIGIN_CHANNEL_ID 单独触发离线 fail-closed 这条臂目前没有测试覆盖(删掉全绿),建议补一条。

F3(非阻断):alreadyClosed && queuedAttachments 分支

可达性核实:master 的 CLI 离线 close/prune 闭包只删 previewTarget / codexApp*,从不删 queuedAttachments / dashboardAttachments,所以旧版关的行会带着 queuedAttachments 进这个分支。分支行为正确(closed 行不会再有下一轮,清掉是对的,且不刷新 closedAt),但确实没有测试覆盖(删了全绿)。建议补一条 legacy closed row re-close 的用例。

已核实无问题的面:

  • daemon 侧 closeSession 在 noop 时不 persistRow / Object.assign 是安全的:worker-pool 的 park 回写是无条件跑的,after = getOwnedSession() 始终反映已提交状态(applied 时 Object.assign 过;noop 时行本就在目标态),after !== ds.session 的别名分叉也处理正确,closedAt 不会被二次 close 刷新。
  • JSON 升级窗口的 withFileLockSync 超时归 contended 与 SQLite 侧语义一致:两边都走 isTransientStoreContentionError,同时匹配 SQLITE_BUSYfile-lock timeout,都是「写互斥被别人持有」→ contended;其它错误(EACCES / ENOSPC / 损坏)照常抛出。

测试:9 个相关测试文件 193/193 通过(Node runtime),bun run build 干净。

最终以维护者审阅为准。

credential-only 只掩 device-auth,BOTMUX_HOME 和会话库对子进程仍可写。闸还在,改的是理由,避免后人按「反正写不了」把这臂删掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

F1 依据写错了,已改(ad10d577)。

结论不变:origin-channel 仍 fail-closed。错的是理由——credential-only 只掩 device-authBOTMUX_HOME / 会话库对子进程仍可写(与 worker.ts「leaving BOTMUX_HOME itself live and writable」一致)。正当性是阻断 confused-deputy(被注入的 agent 不能在 daemon 挂掉时离线改行),不是「反正写不了、换成干净报错」。docstring / 测试注释 / PR 描述已按这个改,避免后人按错依据拆闸。

@deepcoldy

Copy link
Copy Markdown
Owner

跟进(第二轮,针对 follow-up f70945649):

F1 行为判定不变,但新加的注释依据写反了,建议合入前修正。

follow-up 的 docstring 与 commit message 写「credential-only 子进程写不了 ~/.botmux,所以 fail-closed 与 sandbox 同理」。这个前提不成立。我用真 bwrap 按 botmux 自己的掩码形状复现(--bind / / + --tmpfs <root>/device-auth + --ro-bind /dev/null <root>/platform.json + --dev /dev):

  • device-auth/ 读不到(掩码有效)✅
  • platform.json 被 mask 成空 ✅
  • data/session-stores/test/sessions.db 被写穿——子进程写入 WRITTEN_BY_SANDBOXED_CHILD,宿主侧回读到该值 ✅
  • ~/.botmux/ 下能新建文件

即 credential-only bwrap 只 mask 凭据权威,~/.botmux 整树(含会话库)对子进程是可写的。这与 worker.ts 自己的注释直接打架——worker.ts:15518 明写 "leaving BOTMUX_HOME itself live and writable"。

所以 fail-closed 的正当性不是「反正写不了」,而是防 confused-deputy:agent 本就能绕开 botmux 直接写库,但官方 botmux delete 路径被关掉后,prompt-injected agent 不能再借这条干净原语离线关会话(要手搓 SQLite 写,门槛高得多)。注释按这个方向写才对。

风险:后人读到「credential-only denies that tree」会以为闸是冗余的、顺手删掉,那是安全回退。建议把 docstring 改成「credential-only 下库仍可写,fail-closed 是防 confused-deputy,不是只读兜底」,commit message 同理(commit 已落盘改不了,至少代码注释要改对)。设计文档 §1「对会话库只有 readOnly 授权」对 credential-only 也不成立,一并修。

已修好的部分(抽查通过):

  • F1 补了 isIsolatedCliProcess 单测 + CLI ORIGIN_CHANNEL_ID 单独触发 fail-closed 用例;F3 补了 legacy closed row re-close 用例。我变异删掉 ORIGIN_CHANNEL_ID arm,2 条转红(单测 + CLI 用例),这条臂现在承重。
  • F2 白板内存谎报改成 bindSessionWhiteboard()(只在 patch 成功后才写内存),逻辑对。但零测试:全仓 grep bindSessionWhiteboard 与错误文案在 test/ 下零命中,建议补一条「patch 失败不写内存」的用例。

作用域核查(#998 / #1229):

测试:10 个相关文件 209/209 通过(Node runtime),bun run build 干净。

最终以维护者审阅为准。

bindSessionWhiteboard 抽出可测实现:persist 失败时不改 session.whiteboardId,并覆盖失败文案。进程退出后磁盘看不出这场谎报。

Co-authored-by: Cursor <cursoragent@cursor.com>
@LucasIcarus

Copy link
Copy Markdown
Collaborator Author

F2 补测了(抽出 bindSessionWhiteboardsession-whiteboard-bind.ts)。

进程退出后磁盘本来就不会有这场谎报(patch 失败本来就不落盘),所以必须测内存。新用例:persist === falsewhiteboardId 仍是 undefined / 保持旧值;成功才写入。失败文案 未能绑定到会话 也在测试里。

设计文档写「沙盒内进程对会话库只有 readOnly 授权」,这只对 full sandbox 成立。
credential-only 的 bwrap / Seatbelt 只掩 device-auth 与根级凭据文件,BOTMUX_HOME
(含 session-stores/)对子进程仍可写——真 bwrap 实测子进程能写穿会话库。
措辞改成与 isIsolatedCliProcess 的 docstring 一致:fail-closed 的依据是 confused-deputy,
不是「反正写不了盘」,避免后人据此删掉 origin-channel 判定。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

四轮自动评审 + 交叉复审均 0 阻断,非阻断项已全部处理;合并树已验证与本地验证过的树一致。

@deepcoldy
deepcoldy merged commit ae486c4 into master Sep 7, 2026
9 of 10 checks passed
@deepcoldy
deepcoldy deleted the feat/session-single-apply branch September 7, 2026 09:46
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

🚀 Released in v3.19.3

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