Skip to content

feat(workspace): 添加会话回收与持久化资源验证入口 - #1372

Open
Gundam98 wants to merge 2 commits into
deepcoldy:masterfrom
Gundam98:task/weekend-20260914-botmux-workspace-session-recycle/botmux/da4d40b7
Open

feat(workspace): 添加会话回收与持久化资源验证入口#1372
Gundam98 wants to merge 2 commits into
deepcoldy:masterfrom
Gundam98:task/weekend-20260914-botmux-workspace-session-recycle/botmux/da4d40b7

Conversation

@Gundam98

@Gundam98 Gundam98 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

工作区目录回收后,相关 Botmux 会话仍可能保留活跃记录和常驻 worker。本变更增加可显式配置的 workspace-recycle 入口,在外部生命周期成功事件之后关闭已准备的精确目标,并输出可恢复的逐会话资源回读。

  • 以会话自身 workingDir 与路径段边界发现跨 Bot、群、话题目标,规范化目录别名;排除外部/共享会话,严格检查 store 覆盖。
  • 提供 discover / prepare / finish / status 及中立 JSON Hook;各在线 owner daemon 通过主机 HMAC 执行标准关闭,持久日志位于目标工作区之外。
  • 忙碌、新输入、身份变化或覆盖缺口会阻止关闭;发起会话先持久交接,其他目标成功后待空闲最后关闭。失败事件、重复事件、部分关闭和丢失回执均有明确恢复语义。
  • 成功回收关闭时原子持久化退役标记。通用 resume、CLI/卡片/API 和会话群自动续聊拒绝恢复该会话,会话群不会因拒绝而隐式新建。旧对象保存不能抹掉退役标记,重建同名目录也不能复活旧会话。
  • 区分 closed、pending、partial、closed_with_residual,回读活跃注册、出生身份绑定的 PID、RSS/FD/inotify 以及适用持久后端资源,保留标准关闭后的历史记录。

验证:bun run build 通过;本轮 15 个测试文件共 435 项通过,包含 4 个工作区回收测试文件,以及会话存储/恢复、真实会话群路由和标准关闭回归。隔离真实 worker 连续 3 轮创建 HTTP listener 与 inotify watcher,标准关闭后 PID 消失、活跃注册为 0、关闭历史保留;随后重新读取 SQLite 并调用通用 resume,仍保持 closed / 0 active。会话群测试通过真实 finish(succeeded) 和真实 daemon 路由验证重启读取后拒绝自动恢复与新建。另覆盖失败写盘、锁等待期间退役、旧对象覆盖及独立关闭后的补记。

接入边界:当前使用等价生命周期事件与独立入口验证,实际外部通用 Hook 的时序和失败传播仍需接入后验证。Linux 资源观测有真实进程证据;无法证明所属进程集合的平台或远端残留按 blocker 报告。历史目录缺失仅生成只读候选,不自动清理。详见 docs/workspace-recycle.md

CI:run 34681204358 已完成且 9/9 jobs 通过,包括 3 个完整单元测试分片、Bun 运行时测试、完整构建及 Linux glibc/musl/macOS 二进制验证;对应 HEAD 1caa228601b25f911a76733d60014c5a760cd481

@Gundam98
Gundam98 requested a review from deepcoldy as a code owner September 12, 2026 06:56

@Gundam98 Gundam98 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

基于 exact HEAD 74ef2d5c4285f52b81f7ca89cfc93229b40928aa 完成首次 Review。结论:需修改。当前有 1 个 P1 阻断项:成功回收后的 closed session 仍可被通用/自动 resume 原地复活,重新产生指向已删除工作区的 active 记录。请修复后在同一 PR 请求复审;本轮不授权合码或发布。

Comment thread src/core/workspace-recycle-runtime.ts

@Gundam98 Gundam98 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

基于 exact HEAD 1caa228601b25f911a76733d60014c5a760cd481 完成复审。结论:通过,无阻断项

原 P1 已按要求闭环:退役标记与 closed 原子持久化;通用 resume 的首次读取、锁内重读与 store reactivate 均拒绝退役,durable 整行写入也防止旧对象覆盖;restore/registry 排除退役;会话群拒绝后明确回复并直接返回,不会隐式新建。成功回收后的真实 worker/SQLite 重载恢复和真实 daemon 会话群路径均有回归。

独立验证:关键 6 文件 179 项通过;等价完整 build 链路通过;远端 CI run 34681204358 对应该 exact HEAD,9/9 jobs success;agent-task doctor 16 pass / 0 drift / 0 error,工作树 clean。首次交接列明的 006 真实通用 Hook 接入与 legacy strict-discovery 覆盖边界仍然保留,不影响本次 P1 修复结论。

原 P1 线程已回复并解决。当前 HEAD 无需再次复审;若后续 HEAD 变化,需要按新 SHA 重新确认。本 Review 不执行合码或发布。当前 GitHub 连接身份与 PR 作者相同,平台不能形成 self-approval,因此以 COMMENTED review 记录上述工程通过结论。

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,整体设计质量很高。协调器不持 store、只让每条会话自己的在线 owner daemon 执行关闭;路径用段边界 + realpath 归一而不是字符串前缀;发起者持久交接后最后关闭;退役标记与 closed 原子共写且陈旧整行写擦不掉——这几处都考虑得很细。

先说验证情况:本地在最新 origin/mastere636f93cb)上核对,本分支 0 behind、无需 rebase、无冲突;CI run 34681204358 9/9 通过。相关 5 个测试文件 178/178 通过。做了三发反变异(改坏源码看测试是否转红):去掉 persistRowworkspace_retired 抛错 → 转红;删掉 applyClose 里写入退役标记的 4 行 → 4 个文件转红;去掉 restoreActiveSessions&& !s.workspaceRetirement → 全绿,即这条过滤目前没有任何测试覆盖

下面是建议修改的几点,按优先级排列。

1. restoreActiveSessions 的退役过滤是必需闸门,但既无覆盖也可被绕过(建议优先处理)

原本以为「退役标记只与 closed 原子共写 ⟹ active + retirement 不可达 ⟹ 这条过滤只是纵深防御」。实测发现并非如此:persistRowsrc/services/session-store.ts:1668)只在磁盘上那行已退役时才校验一致性;如果磁盘行还没有退役标记,传入一个「带 workspaceRetirementstatus='active'」的对象,会零校验直接落盘。最小复现:

createSession → updateSession({ ...session, workspaceRetirement: {...} })
⟹ 磁盘行同时是 status='active' + workspaceRetirement

也就是说 restoreActiveSessions 这条过滤是最后一道闸:真出现这种行时,正是它拦住了 daemon 重启后把已退役会话拉活。但它现在既没有测试(上面的反变异全绿),删掉/写错也不会有任何测试报警;命中时还是完全静默,一行日志都不打,运维侧无法分辨「这个会话为什么没恢复」。

建议三件一起做:

  • persistRow 入口补一条传入侧校验,把上面的 gap 堵死:

    if (session.workspaceRetirement && session.status !== 'closed') throw new Error('workspace_retired');

    本地验证过这条不影响正常关闭路径:applyClose 是先把 row.status 置为 'closed'persistRow,所以正常 close 传入的一定是 closed + retirement;加上这条之后,上面的最小复现转为抛错,而「正常 close」「对已关闭行补记退役标记的幂等再关闭」两条路径行为不变。回归上跑了本 PR 相关 5 文件 178/178、以及 close/restore 相关的另外 10 个文件 159/159,全绿。

  • 补一条 restore 层用例:构造一行 active + retirement(或走 close 路径造 closed + retirement),断言 restoreActiveSessions 跳过它、不注册、不 spawn。

  • 命中时打一行日志(debug/info 皆可,带 sessionId 与 operationId),否则这条静默跳过在真实排障时无从分辨。

2. 两处入口把裸错误码 workspace_retired 直接展示给用户

PR 已经为退役场景加了 i18n 文案 card.action.resume_workspace_retired,CLI (src/cli.ts:6199)、命令处理 (src/core/command-handler.ts:3385)、卡片 (src/im/lark/card-handler.ts:3032)、会话群自动续聊 (src/daemon.ts:18146) 四处都用上了。但还有两处走的是通用错误透传,会把 workspace_retired 这个原始 token 原样显示:

  • src/im/lark/group-sessions-card.ts:369-370reason = String(response.body?.error ?? ...) 后直接 error('card.group_sessions.resume_failed', { reason })
  • src/dashboard/web/sessions-page.tsx:3441toast(\${t('sessions.resumeFailed')}: ${body?.error ?? r.status}`)`

由于 POST /api/sessions/:id/resume 路由是通用转发(把 result.error 原样带 409 返回),改在路由层不合适;建议在这两个消费侧对 workspace_retired 做一次映射,复用已有的 i18n key。

3. 日志自相矛盾(顺手可改)

src/core/worker-pool.ts:7243 现在会驱逐「status === 'active' 但已退役」的注册,随后打印的是 Refusing to register an inactive session (status=active)——这句话在退役分支下自相矛盾,排障时会误导。建议按分支区分文案。

其余非阻断观察(不影响合入,供参考)

  • src/core/dashboard-ipc-server.ts:1223getSession 内部调了 sessionStore.listSessionsStrict(),它会触发 migrateCodexInstanceBindings()BEGIN IMMEDIATE 写事务)。一个读形状的依赖带着写副作用,每次回收 IPC 都会跑一遍,值得确认是否必要。
  • persistRow 新增的抛错是一条仓库级不变式:store 之外有 142 处 updateSession( 调用点(worker-pool 60、daemon 31)。逐个文件核过之后判断当前没有能真正触发它的活路径(所有调用点传的是活对象或 store 内存行,关闭路径会同步置成 closed + retirement),属于设计内的 fail-closed;只是建议在 PR 描述里点名这条横切不变式,方便后续改动者知晓。
  • test/workspace-recycle.integration.test.ts:41 的 fixture 就绪超时硬编码 10s。在高负载机器上复现过超时(单独跑约 1.9s 通过),判断是环境因素而非逻辑缺陷,但 CI 并行分片下存在 flake 风险,可考虑放宽或按环境伸缩。
  • CLI 推断「发起者」时会信任 BOTMUX_SESSION_ID 环境变量兜底(resolveSessionContext 末行)。发起者是特权角色(prepare 阶段允许忙、最后才关闭),好在被「必须出现在 discover 结果中」这条约束兜住,风险有限。
  • 新增 POST 路由的 readJsonBody 未传 maxBytes——同文件 49 个调用点里 44 个也没传,属跟随现状,仅作记录。

以上是自动评审给出的初步意见,可能有误判或遗漏,最终以维护者审阅结论为准。其中第 1 点(persistRow 传入侧校验 + restore 用例 + 日志)建议优先处理,其余可按需取舍。

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