refactor(schedule): 定时任务存储按 bot 拆分到 BOT_HOME——修复沙盒内锁 EPERM、消除跨 bot 任务泄漏 - #611
Conversation
…任务泄漏 存储从共享 data/schedules.json 迁至 <botmuxHome>/bots/<appId>/schedules.json: - 沙盒策略零新增规则:own BOT_HOME 本就 readWrite、兄弟构造性 deny——RMW 兄弟锁(schedules.json.lock)随文件进入 rw 目录,两平台的沙盒内 schedule 增删改同时修复(旧单文件放行盖不住兄弟锁路径,EPERM) - 消除旧共享文件被迫接受的泄漏面:任务 prompt/routing 不再对全体沙盒 bot 可读 - fs-policy 删除共享文件特殊放行;worker 删除预建 store 增加 per-bot scope(daemon 绑自己、CLI 绑会话 bot/--lark-app-id)与 per-file 状态机;createTask 按 params.larkAppId 路由到属主文件;新增 listTasksForBots/findTaskAcrossBots(非沙盒聚合/跨库寻址)与 importTasks。 daemon 启动时把 legacy 共享文件按 task.larkAppId 幂等拆分(无归属→bot-0, 同 owner-filter 旧语义;并发 boot 由 legacy 文件锁串行化;原文件保留为 .bak-split-v1,降级手工改名即回)。 codex 两处 P1 已修:v3 reconciler 从冻结 input 取 larkAppId 寻址(非 daemon 运行无全局 scope);CLI add 的 owner 回退链对齐 scope 解析(flag→marker→env), 防止沙盒会话产出 ownerless 任务永不执行。 真机验证:sandbox-probe 新增锁文件断言全绿;真实 Seatbelt profile 内 schedule add/list/rm 端到端通过,落点 per-bot 文件。
…hboard-ipc 绑定测试 scope
首次 review(Claude)— 结论:改动逻辑正确、无阻塞项,可合(前置:@申晗 确认 + @codex 独立复审)按仓库「影响范围评估」逐条核过承重事实,本地实跑验证如下。 这个 PR 在解决什么共享的
怎么解的存储从一个共享文件拆成 per-bot: 我核过的承重事实(全部成立)
本地验证
一个非阻塞观察点(P3,供 @codex 独立复核)
以上为首审。@codex 请独立复核 diff / 测试 / 跨 bot·跨后端影响面。未经 @申晗 确认不合码。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 独立复审 — 结论:请求修改
核心方向成立:per-bot store 与 BOT_HOME 的授权公式一致,RMW sibling lock 在 macOS/Linux 都进入 owner 的可写目录;PTY/Tmux/普通/restore/adopt 的 CLI 子进程都能拿到 BOTMUX_LARK_APP_ID,daemon/dashboard 的零参调用也在启动时绑定了 scope;迁移的 legacy-file lock、锁内复查和“全部 import 完才 rename”可应对并发启动与部分失败。未发现跨 CLI 或跨后端的架构性回归。
但独立复核发现 2 个需要先修的缺口:
1. 阻塞:未知 owner 被写入 primary store 后,实际上没有任何 daemon 会执行
src/services/schedule-split-migration.ts:71 对“不在当前 knownAppIds 的 task.larkAppId”选择 primaryAppId 作为目标文件,但 importTasks() 保留任务原来的 larkAppId。因此迁移后形成:
- 文件位置:
bots/<primary>/schedules.json - 任务 owner:仍为旧的/暂未配置的 appId
- primary scheduler:
taskBelongsToThisDaemon()看到非空 owner 且不等于 primary,返回 false - 将来重新加入原 bot:它只读
bots/<原 appId>/schedules.json,那里又是空的
隔离目录实测结果:
{"primaryStoreHasTask":true,"taskOwner":"cli_removed","primaryWouldExecute":false,"ownerStoreExists":false}这不是注释所说的“unknown owners go to primary”执行兜底,而是把任务永久搁置;backup 虽在,但正常运行路径已不可达。建议明确语义后二选一:安全 appId 仍落到自己的 store 以保留未来恢复,或回退 primary 时同步清掉/改写 owner,使 primary 真的接管。迁移测试不能只断言 key 在 primary 文件里,还应断言 owner filter 的实际执行归属以及 bot 恢复后的可见性。
2. 阻塞:浏览器 E2E 的定时任务清理器没有绑定新 scope,所有兜底清理都失效
新 schedule-store 的零参 removeTask/listTasks 会经 requireScope() 抛错,但 test/e2e-browser/schedule-cleanup.ts:41-43,65-80,93-110 仍直接导入 store 后零参调用。隔离临时目录复现:
{"removed":[],"warnings":["removeTask(missing-id) threw: [schedule-store] no bot scope bound — call setScheduleScope(<larkAppId>) before using the schedule store","listTasks() threw: [schedule-store] no bot scope bound — call setScheduleScope(<larkAppId>) before using the schedule store"]}后果是 feishu-schedule.e2e.ts 创建的真实任务在 UI 清理失败时无法兜底删除,global setup 的 orphan sweep 也固定空跑,可能持续往真实群发消息。这里不能只绑定 primary:E2E 可能由任意 bot 创建任务。建议让该 unsandboxed helper 枚举配置中的 appId,显式按 store 查找/删除,并补一个 temp per-bot store 回归测试。
验证记录
pnpm build:通过- PR 直接相关 8 套:208/208 通过
pnpm test:10816 passed / 4 failed / 5 skipped;4 个失败在未含 PR 的基线可完全复现(3 个 host TZ、1 个 root 下真实 bwrap mode-000),与本 PR 无关- 当前
origin/master=b30e8949,PR 基于945c332a:git merge-tree --write-tree origin/master HEAD无冲突;合成 merge treepnpm build通过,相关 8 套串行 209/209 通过 - worktree 保持干净;未改代码、未重启 daemon、未合码
修完这两点后请再 @ 我复审。未经申晗确认仍不要合码。
…tore
codex 独立复审抓出 2 个阻塞缺口,均已修复并补回归测试(变异测试证对实现敏感):
1. 迁移「未知/未配置 owner」搁置(finding 1,已 runtime 复现)
- 病因:schedule-split-migration 把 larkAppId 不在 bots.json 的任务写进 primary
的 store 却保留原 larkAppId;primary 的 owner filter 因 appId 不匹配拒绝执行,
而该 owner 自己的 store 又是空的 → 任务永不触发。
- 修法(按 codex 建议区分三种归属,而非一律塞 primary):
· 真 ownerless(无 larkAppId)→ primary 的 store,保持 ownerless(primary daemon
本就执行 ownerless,等同拆分前行为;不 stamp primary appId)
· owner 在 bots.json → 自己的 BOT_HOME store(不变)
· owner 合法但当前未配置(bot 被删/配置漂移)→ 落自己的 dormant store(非 primary),
保留 larkAppId,bot 将来恢复即原样可见(塞 primary 要么搁置、要么以错误 bot 身份执行)
· larkAppId 不安全(无法做路径段)→ fail-safe:import 前中止整次拆分、保留 legacy
文件交人工,绝不静默吞行
2. 浏览器 E2E 清理器全部失效(finding 2)
- 病因:test/e2e-browser/schedule-cleanup.ts 仍零参调用 removeTask/listTasks,
per-bot store 固定抛 no bot scope bound;UI 清理失败后真实测试任务无法兜底删除,
orphan sweep 也空跑。
- 修法:候选 ID 删除、按 label fallback、orphan sweep 三条路径全部枚举
<botmuxHome>/bots/<appId> 下的 store 并显式传 appId(findTaskAcrossBots /
listTasksForBots / removeTask(id, appId)),不再依赖任何 bound scope。
测试:
- 新增 test/schedule-cleanup-per-bot.test.ts(4 用例:跨 store 删候选 id / label fallback /
orphan sweep / 空 store no-op)
- 更新 schedule-split-migration.test.ts:未配置-但安全 owner → 自己 store 保留 appId、
ownerless → primary 保持 ownerless、不安全 appId → 中止且保留 legacy
- 变异测试:还原旧 buggy 路由 → 迁移 2 用例红;还原零参调用 → 清理 2 用例抛 no bot scope
- pnpm build 绿;PR 相关 9 套件 213/213;全量 unit 10815 passed / 10 failed,
10 个失败全环境基线(TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真 bwrap),
全非本次改动文件,零 no-bot-scope 泄漏
Co-Authored-By: Riff <noreply@riff.dev>
代作者修复:采纳 Codex 复审的 2 个 blocker(新 head
|
|
To use Codex here, create a Codex account and connect to github. |
…only + 非字符串一律 fail-safe codex 二轮复审在迁移 fail-safe 边界又抓出两个真实缺口(均已 runtime 复现): - finding 3:truthy 非字符串 larkAppId(最小例 boolean `true`)——`assertSafeAppId` 的 RegExp.test 把它隐式转成 "true" 通过,任务被迁进 bots/true/、legacy 被删, 而恢复 appId="true" 的 bot 时 owner filter 严格比较 true !== "true" 仍不执行。 - finding 4:falsy 非字符串(`false`/`0`/`null`)——原 `if (!task.larkAppId)` 把它 当 ownerless 塞进 primary,且 primaryWouldExecute=true,会用**错误 bot 身份执行**, 比 finding 3 的搁置更危险。 根因:owner 判定既没区分「字段缺失(真 ownerless)」与「字段存在但类型错」,又在 路径安全校验(assertSafeAppId)之外漏了类型校验。 修法(按 codex 建议一次收紧,覆盖全部 falsy/truthy 非字符串 + 空串): - 仅 `larkAppId === undefined` 才算 ownerless → primary 保持 ownerless - 其余先要求 `typeof === 'string'`,非字符串(bool/number/null/object,truthy 或 falsy)一律 fail-safe:中止整次拆分、保留 legacy、import 前零写入 - 再对字符串跑 assertSafeAppId(空串/路径穿越同样 fail-safe) - 配置内→自己 store,合法未配置→自己 dormant store(保 appId,不变) 测试: - schedule-split-migration.test.ts 的 fail-safe 用例表扩到 true/false/123/0/null/''/object 七种,断言中止+保留 legacy+无任何 coerced 名 store 目录(bots/ 下零 sibling) - 变异测试:把判定还原成 `!task.larkAppId` → false/0/null/'' 四例转红, 证明 undefined-only 判定正是 finding 4 的修复点 - pnpm build 绿;PR 相关 9 套件 220/220;全量 unit 10822 passed / 10 failed, 10 个失败全环境基线(TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真 bwrap),全非本次改动文件,零 no-bot-scope 泄漏 Co-Authored-By: Riff <noreply@riff.dev>
代作者修复(第 2 轮):采纳 Codex 二轮复审的迁移 fail-safe 边界缺口(新 head
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
复审结论:APPROVE,当前无阻塞项。本次批准只表示代码复审通过;按群内约定,未经申晗明确确认仍不得合码。
复核 head:5114a79d9a716650d937522ff6bf6132d407858c。
原先四个阻塞点均已关闭:
- 未配置但合法的 owner 不再折叠进 primary,而是进入自己的 dormant store 并保留原
larkAppId;ownerless 仍只由 primary 承接且不 stamp。 - E2E 清理的候选 ID、label fallback、orphan sweep 三条路径均枚举所有 per-bot store,并显式携带
_storeAppId/appId;无零参 store 调用。 - truthy 非字符串 owner 不再被
RegExp.test隐式转成路径后静默搁置。 - falsy 非字符串 owner 不再被误判为 ownerless、落入 primary 并以错误 bot 身份执行。现在只有
larkAppId === undefined才算 ownerless;其余先做字符串类型校验,再做路径安全校验,失败均在 import 前中止并保留 legacy。
独立复现验证:旧 head 上 larkAppId:true 会落入 bots/true/ 且恢复后仍不执行;larkAppId:false 会进入 primary 且 primaryWouldExecute=true。新 head 上 false 最小复现变为 legacyExists=true / backupExists=false / primaryStoreExists=false,符合 fail-safe。
独立测试:
pnpm build:通过。- PR 相关 9 套件:220/220 通过(迁移 15 例,含 true/false/123/0/null/空串/object;E2E per-bot cleanup 4 例)。
pnpm test:10828 passed / 4 failed / 5 skipped。4 个失败均为本机既有环境基线:3 个宿主时区非 +8,1 个 root/DAC_OVERRIDE 下真 bwrap 可读穿 000 mask;没有no bot scope,失败文件均非本 PR 修改文件。- 与最新
origin/masterb30e8949合成树a7e6376c:无冲突,pnpm build通过,相关 9 套件 221/221 通过(master 新增 1 个 dashboard 测试)。
影响面结论:per-bot 路径公式与 BOT_HOME 隔离一致;共享 sandbox 特判删除后 macOS/Linux 均回归到 bot 自有目录授权;调度 owner 语义、dashboard 观察结果、普通/v3 host 调用及 E2E 管理清理均有对应覆盖。没有发现新的跨 bot、跨后端或迁移数据安全问题。
✅ 已合并(申晗授权 admin-merge)
合并前的最终校验(master 自 codex 复审基线 b30e894 已前移到 459251f,故重验)master 期间并入了 #588、#621(含 worker/dashboard 改动,与本 PR 的
未执行发版(tag);未切换/重启 live daemon——按约定二者需另行授权。 |
问题
共享的
data/schedules.json有两个结构性问题:withFileLockSync,需要创建兄弟文件schedules.json.lock——单文件规则盖不住兄弟路径,沙盒内botmux schedule add/rm/pause一律 EPERM。macOS 一行放行能修,但 Linux bwrap 无法绑定"时有时无"的瞬态锁文件,共享文件模型下无干净解。方案:存储按 bot 拆分进 BOT_HOME
<botmuxHome>/bots/<appId>/schedules.json。BOT_HOME 对 owner 本就整目录 readWrite、对兄弟构造性 deny——沙盒策略零新增规则,RMW 兄弟锁随文件进入 rw 目录,两平台同时修复;跨 bot 泄漏面随共享文件一起消失;fs-policy 里那条特殊放行直接删除。架构契合:任务本就带
larkAppId归属,每个 daemon 本就只执行自己 bot 的任务(setOwnerFilter)——"共享文件+各自过滤"变"各读各的文件",调度侧不变。改动
--lark-app-id)+ per-file 状态机;createTask按params.larkAppId路由到属主文件;新增listTasksForBots/findTaskAcrossBots(非沙盒终端聚合 list、跨库 id 寻址)与importTasks(迁移用)schedule-split-migration.ts):daemon 启动时把 legacy 共享文件按task.larkAppId拆分(无归属/未知归属→bot-0,与 owner-filter 旧语义一致);多 daemon 并发 boot 由 legacy 文件锁串行化、锁内复查存在性;幂等;原文件保留为schedules.json.bak-split-v1(降级改名即回);malformed 文件保留原地不 rename(不吞任务、不 brick 启动)--lark-app-id → session marker → BOTMUX_LARK_APP_ID env,任务 owner 用同一解析结果;bots.json 读取改为惰性+容错(沙盒内loadBotsJson会 process.exit);沙盒内跨 bot 写给出明确错误botmux-schedulereconciler 从冻结 input 的larkAppId寻址(非 daemon 恢复路径无全局 scope)测试与实测
master基线按失败文件名对照零回归(仅有的 3 个新失败是测试自身适配,已修:fs-policy 断言、host-executor/dashboard-ipc 绑 scope);schedule 相关 192 用例全过;新增迁移专项 7 用例(拆分/幂等/冲突保留/malformed 保护/独立性)schedule add → list → rm,落点 per-bot 文件——旧模型下第一步就 EPERM兼容性
mv schedules.json.bak-split-v1 schedules.json即回(新写入的任务需手工并回,PR 说明即此一条)schedule list聚合所有 bot、id 操作跨库寻址,管理体验与之前一致