refactor(session): 会话行命令统一 apply,CLI 与白板离线写改走同一实现(Stage 2) - #1280
Conversation
0060dfa to
c28ca67
Compare
落地设计文档 §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>
c28ca67 to
c2844aa
Compare
BOTMUX_ORIGIN_CHANNEL_ID 只盖在 worker 拉起的隔离子进程上,host shell 即使机子做过 device 登记也不会带它;credential-only 子进程同样写不了 ~/.botmux,daemon 不可达时 fail-closed 是有意的。白板绑定失败不再把 id 写进内存。补上已关闭行残留附件与宿主删目录的测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
|
预先意见处理: F1 成立一半。 F2 成立,顺手修了。 改前就丢返回值;本 PR 多了 F3 成立,已补测。 已关闭行再 close 仍清 F4 成立,已补测。 全量失败口径:不是 21 条业务回归。本机红的是 bun 1.4.0 vs 钉死的 1.4.1 版本断言,外加全量负载偶发超时。 |
|
自动评审核对意见(第二轮): 整体结论:0 阻断,建议合入前确认 F1 一处。 F1(建议确认): 链路核实无误:
需要确认的一点:credential-only bwrap 下会话库不是只读的—— 这个行为变更本身是合理的硬化(防 prompt-injected agent 离线改会话行;用户在 host shell 跑 botmux 不受影响,daemon 在时 IPC 也不受影响),但设计文档 §1 写的「跑在 bwrap / Seatbelt 里的进程对会话库只有 readOnly 授权」对 credential-only 不成立,建议修正措辞,避免后续读者误判边界。另外 F3(非阻断): 可达性核实:master 的 CLI 离线 close/prune 闭包只删 已核实无问题的面:
测试:9 个相关测试文件 193/193 通过(Node runtime), 最终以维护者审阅为准。 |
credential-only 只掩 device-auth,BOTMUX_HOME 和会话库对子进程仍可写。闸还在,改的是理由,避免后人按「反正写不了」把这臂删掉。 Co-authored-by: Cursor <cursoragent@cursor.com>
|
F1 依据写错了,已改( 结论不变:origin-channel 仍 fail-closed。错的是理由——credential-only 只掩 |
|
跟进(第二轮,针对 follow-up F1 行为判定不变,但新加的注释依据写反了,建议合入前修正。 follow-up 的 docstring 与 commit message 写「credential-only 子进程写不了
即 credential-only bwrap 只 mask 凭据权威, 所以 fail-closed 的正当性不是「反正写不了」,而是防 confused-deputy:agent 本就能绕开 botmux 直接写库,但官方 风险:后人读到「credential-only denies that tree」会以为闸是冗余的、顺手删掉,那是安全回退。建议把 docstring 改成「credential-only 下库仍可写,fail-closed 是防 confused-deputy,不是只读兜底」,commit message 同理(commit 已落盘改不了,至少代码注释要改对)。设计文档 §1「对会话库只有 readOnly 授权」对 credential-only 也不成立,一并修。 已修好的部分(抽查通过):
测试:10 个相关文件 209/209 通过(Node runtime), 最终以维护者审阅为准。 |
bindSessionWhiteboard 抽出可测实现:persist 失败时不改 session.whiteboardId,并覆盖失败文案。进程退出后磁盘看不出这场谎报。 Co-authored-by: Cursor <cursoragent@cursor.com>
|
F2 补测了(抽出 进程退出后磁盘本来就不会有这场谎报(patch 失败本来就不落盘),所以必须测内存。新用例: |
设计文档写「沙盒内进程对会话库只有 readOnly 授权」,这只对 full sandbox 成立。 credential-only 的 bwrap / Seatbelt 只掩 device-auth 与根级凭据文件,BOTMUX_HOME (含 session-stores/)对子进程仍可写——真 bwrap 实测子进程能写穿会话库。 措辞改成与 isIsolatedCliProcess 的 docstring 一致:fail-closed 的依据是 confused-deputy, 不是「反正写不了盘」,避免后人据此删掉 origin-channel 判定。
deepcoldy
left a comment
There was a problem hiding this comment.
四轮自动评审 + 交叉复审均 0 阻断,非阻断项已全部处理;合并树已验证与本地验证过的树一致。
|
🚀 Released in v3.19.3 |
改了什么
落地
docs/design/2026-08-12-session-restage-store-first.md§3 的 Stage 2「单一 apply 路径」(依赖已合入的 #1202 Stage 1 occupancy):src/services/session-commands.ts:close/prune/whiteboard/worker-exited四条命令对会话行的唯一变换applySessionRowCommand(row, command, { now })。纯函数、不做 I/O;结果是applied/noop/refused(reason)。对已关闭行再 close 是noop,不再刷新closedAt。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.ts(applySessionCommandAsHost/readSessionRowAsHost/isOccupancyHeld)。CLI 的delete/list自动 prune /whiteboard与 dashboard 删板解绑都走它;CLI 私有的三份 close 字段清单删除。owned/missing/contended三种「不是本进程能动的」情形分开报告,不再用undefined混同;CLI 相应给出session_row_missing/session_store_busy。tokenUsage、parkMojoLineage、parkLocalResidual、clearRiffParentTaskId、clearMojoCloseJournal)在HostSessionCommand上类型化为never:宿主构造不出能抹掉 mojo 对账栅栏或钉死 token 快照的命令(tsc 可检查,test/session-commands.test.ts有@ts-expect-error钉住)。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-auth、BOTMUX_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 条)
tokenUsage:宿主 shell 未必能解析 BOT_HOME 下的 transcript(解析器依赖SESSION_DATA_DIR环境变量),落一个永久null会让 dashboard 对该已关闭会话停止实时计算。daemon 侧采样与写入逻辑逐字保留。mojoCloseJournal(与改前离线 close 一致);daemon 的 store close 仍在自身 prepare 之后抹除。delete的codexAppDispatchLedger/codexAppGenerationCommits/queuedActivation*/pendingRepoSetup,现在与 daemon 一致地保留在已关闭行上(daemon 从未删过;resume 时reactivateClosedSession清)。dashboardAttachments/queuedAttachments,并在 commit 后用与 daemon 同一个cleanupMaterializedDashboardImages删掉 materialized 目录(以前离线 close 留着字段、目录永远无人清)。影响面
services/session-store(closeSession内部、离线事务原语)、services/session-command-host(原 offline-write)、core/dashboard-ipc-server白板路由、cli.tsdelete / list prune / whiteboard、services/whiteboard-store删板解绑、core/managed-origin-capability新增分类函数。expectAdopted前置条件保留。Pty / Tmux / zmx / herdr 的 backing 拆除代码未动。riff / mojo:宿主 close 不再可能抹 journal,daemon 路径不变。owner: false不受影响(不走这些入口)。__dirname/ 路径拼接。测试验证
bun run build通过;tsc --noEmit干净。test/session-commands.test.ts(11 条:各命令的字段效果、幂等 noop、拒绝原因、类型边界)。test/session-occupancy.test.ts、test/session-store.test.ts、test/session-store-sqlite.test.ts到命令 API:断言的不变量不变(新鲜行而非快照、abortIf入口 + 发布前双探测、SQLITE_BUSY报contended、绝不创建空库、JSON 升级窗口),新增readSessionRowUnowned同门槛、expectAdopted拒绝、JSON 文件锁竞争报contended。test/session-delete-cli.test.ts新增:宿主离线 close 不写tokenUsage;BOTMUX_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/dashboardAttachments。test/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 开放行 →closed、previewTarget清除、不写tokenUsage;BOTMUX_SEND_RELAY下无 daemon → 报「隔离会话内不能离线修改会话」、行保持active。Dogfooding
bun run switch:here && bun run daemon:restart(2026-09-06 14:20 UTC,supervisor 下 4 个 bot + dashboard):session-stores/<appId>/sessions.db都由新 pid 持有occupancy租约(重启后 79s 内续期正常)。Restored 5 session(s),两个 worker 从 journal 恢复中断轮次;重启后各 bot 的daemon-*-err.log/daemon-*-out.log无 ERROR / TypeError / 模块缺失。botmux send/botmux whiteboard current/botmux status走新 build 正常。/whiteboard路由的 daemon 侧改动由test/ipc-whiteboard-route.test.ts(6 条,含 409 CAS)覆盖,未做 live 验证。test/session-delete-cli.test.ts与上面编译产物实测覆盖,未在真实沙盒会话里点过botmux delete。