Skip to content

feat(scheduler): 支持定时任务 Bash 前置条件、执行日志与多群绑定 - #1187

Merged
deepcoldy merged 12 commits into
masterfrom
feat/schedule-bash-precondition
Sep 6, 2026
Merged

feat(scheduler): 支持定时任务 Bash 前置条件、执行日志与多群绑定#1187
deepcoldy merged 12 commits into
masterfrom
feat/schedule-bash-precondition

Conversation

@anarkh

@anarkh anarkh commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

目标

为定时任务增加可选 Bash 前置条件、执行日志和多群绑定,并保证未配置或关闭前置条件的任务继续沿用原模型调用流程。

当前行为

Bash 前置条件

  • 支持内联 Bash 与 Bash 文件两种配置,并提供独立启停开关。关闭只暂停执行并保留配置;清空内容后保存才会移除配置。
  • 只有 Bash 退出码为 0 且 stdout 去除首尾空白后严格等于 1 时才调用模型。stdout 为 0、空白或其他内容时记为“前置条件未通过”;启动失败、超时、文件读取失败和非零退出记为调度失败。两种情况都不会调用模型。
  • 脚本可通过 FD 3 输出仅对本次执行生效的追加 Prompt;不会改写任务原始 Prompt。stdout 只用于放行判断。
  • 默认超时 30 秒;stdout/stderr 合计上限 64 KiB,FD 3 上限 64 KiB。判定会等待进程与输出流都结束,避免遗漏尾部输出。

文件模式的可信目录

  • 文件模式只接受 daemon 主机上的完整绝对路径,且文件必须位于当前 Bot 的动态目录 <dataDir>/schedule-preconditions/trusted-files/ 内。
  • 拒绝相对路径、~、目录外文件、目录本身,以及任一路径段中的符号链接;文件需为 daemon 可读的普通 UTF-8 文件。可信目录由 daemon 创建并按宿主机私有目录保护。
  • Dashboard 从 daemon 获取当前 Bot 的真实目录,展示可直接填写的完整路径示例和三步配置 Demo。文件会在每次测试和正式调度时重新读取。
  • 既有目录外路径不会被自动复制、改写或删除;未修改文件绑定时仍可保存其他字段。正式执行保持 fail-closed,测试、重新启用或替换路径前需由用户手动迁移到页面显示的可信目录。
  • “测试前置条件”使用当前表单中尚未保存的内容真实执行一次,但不保存任务、不调用模型、不写执行日志,也不计入重复次数;脚本自身的文件、网络等副作用仍会真实发生。

调度记账与执行日志

  • 前置条件未通过使用独立的 skipped 状态:不增加 repeat.completed,不自动删除有限次数任务,也不永久禁用一次性任务。自然调度与 Dashboard“立即运行”使用同一套结果处理。
  • 一次性任务被跳过后保持启用并再次检查;已禁用任务不会因手动运行被重新启用。前置条件通过后仍按原有成功/失败规则记账。
  • 每个任务保留最近 100 条执行日志,记录触发方式、耗时、前置条件结果、是否追加 Prompt、模型提交状态和错误详情。
  • non_zero_exit 保留完整错误类型与真实退出码;单群或多群提交失败记录具体目标及错误正文。任务卡片不展示错误正文,统一在执行日志中查看。
  • “已提交模型”仅表示 Prompt 已交给执行器,不代表模型已生成完成或消息已送达;说明收纳到标签后的 ? 悬浮提示。
  • 任务卡片背景按从旧到新的顺序展示最近执行结果;未配置次数限制时不再显示“重复:—”。

多群绑定

  • 创建和编辑任务时,可按群名或群 ID 搜索并选择该 Bot 已加入的群,单个任务最多绑定 5 个群
  • 前后端都校验 5 群上限且不静默截断。已有超过 5 群的任务不会自动删群或停止执行;保持原绑定不变时仍可修改其他配置,实际变更群绑定时才要求收敛到 5 个以内。
  • 每次调度只执行一次 Bash 前置条件;通过后向各目标群独立提交同一任务 Prompt 和同一份追加 Prompt。单群失败不取消其他群,任一群失败则本次调度结果为失败。
  • 群选择器默认收起,支持搜索、多选、重新加载和加载失败恢复;不再提供重复的手动输入群 ID 入口。绑定逻辑说明位于“绑定群聊”后的 ?

兼容与影响范围

  • 未配置 Bash、关闭 Bash 或存量无前置条件任务不执行脚本,继续走原模型、会话、CLI、PTY/Tmux 和消息投递流程。
  • 单群任务继续使用原 chatId 持久化形状;多群才增加 chatIds。已有超过 5 群的任务按原顺序保留,不做自动迁移。
  • 前置条件配置和运行日志按 Bot/任务隔离;任务删除或有限次数任务正常完成后清理对应 sidecar 与日志,不修改其他任务。
  • 公共状态只暴露前置条件摘要;认证后的 Dashboard 可查看和编辑完整配置。执行日志不保存 Bash stdout/stderr、追加 Prompt 正文或模型输出。
  • 使用 /bin/bash,覆盖 macOS/Linux daemon;未新增生产依赖,未修改 lockfile 或版本号。

UI 示意

任务列表的执行历史背景:

任务列表执行历史背景

执行日志错误详情与“已提交模型”说明:

执行日志错误详情与说明

文件模式当前只支持页面显示的动态可信目录;旧的相对路径配置截图不再作为当前行为示意。

验证

  • npx --yes bun@1.4.0 x vitest run --project unit test/*schedule*.test.ts test/*scheduler*.test.ts:31 个测试文件,652 项通过。
  • 最终提交前复验文件策略、配置事务、Dashboard 布局/交互与 IPC:5 个测试文件,322 项通过。
  • Dashboard 聚合、鉴权/脱敏、daemon 内部 API、错误详情和脚本 runner 补充回归:6 个测试文件,293 项通过(与上述范围有交叉,不累计计数)。
  • npx --yes bun@1.4.0 run build:TypeScript、脚本类型检查、Dashboard 打包、dist 与嵌入资源审计通过。
  • 本 checkout 已部署到 live daemon;只读状态确认 6 个 Bot 与 Dashboard 在线。Dashboard 中已核对动态可信目录、完整路径示例、配置 Demo、迁移提示和测试按钮。
  • 将一个既有目录外文件配置手动迁移到页面指定目录后,下一次自然调度正常得到 precondition_skipped,不再出现路径错误,且未调用模型。未通过“立即运行”制造额外任务执行。
  • git diff --check 与暂存区差异检查通过;未提交 live 任务配置、个人路径或群聊信息。

已知边界

  • 执行日志记录到“模型提交给执行器”为止,不覆盖模型生成完成和最终消息投递结果。
  • 升级前未保存的历史错误正文无法补回;此前因旧记账问题已被禁用或删除的任务也不会自动恢复。
  • 文件配置存量兼容只保留数据,不放宽执行安全边界;目录外旧路径必须手动迁移后才能再次执行。

@anarkh
anarkh requested a review from deepcoldy as a code owner September 2, 2026 02:58
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,整体质量很高——门禁协议定义得很严格,sidecar 的身份回绑、O_NOFOLLOW + before/after 元数据比对、超时后的进程组回收都做得干净。下面是自动评审跑出来的两点建议,供参考。

已复核通过的部分

  • bun run build 通过;PR 相关 17 个测试文件 743 passed / 1 skipped(skip 是 it.skipIf(win32) 的正当平台守卫)。
  • 放行协议按 10 种输入逐条实测,与文档描述逐字一致(101 1、多行 1 均正确判 skip;FD 3 内容在 skip 时被正确丢弃)。
  • sandbox host-only 边界做了反变异验证,确认非惰性代码:删掉 push(hostOnlyRoots, 'deny', 'mandatory') → 测试红;把 finalReadOnlyPaths 还原成不过滤 → 测试红。
  • 超时后进程组回收有效:脚本起后台孙子进程再挂住,超时后子进程与孙子进程全部回收,无残留。
  • 执行日志留存与分页正确(120 条写入后 total=100、最新在前、limit 超限钳到 100、负数钳到下界);删除任务时 sidecar 与日志文件都被清理。

建议一:相对路径的文件型条件,脚本落在沙盒可写区,却在宿主机不受限执行

文件型条件的相对路径以任务 workingDir 为基准解析(schedule-precondition-file.tsresolvedFilePath 只校验非空 / 无 NUL / 无 ~,不检查解析结果落在哪里),而 workingDir 对沙盒内的 CLI 是 readWritefs-policy.ts:733)。脚本触发时由 daemon 直接 spawn('/bin/bash') 执行,不套沙盒。

实测:把 scripts/check.sh 的内容从 echo 0 改成 echo 1 并往 FD 3 写入,下一次触发即 run1=skip → run2=pass,且写入内容进入了 prompt。

也就是说,PR 花了很大力气把脚本正文移进 daemon 私有 sidecar、并为沙盒加了 host-only deny(这部分做得很到位,buildFsPolicy 实测该 sidecar 根确为 deny),但相对路径这一支把「脚本内容」的决定权又交回了沙盒可写目录。UI 占位符目前推荐的 scripts/check-ready.sh 恰好是这种仓库内相对路径。

需要说明可达性边界,这也是我们没有把它定为阻断级的原因:利用前提是 owner 已经配置了落在沙盒可写路径下的文件型条件;沙盒内的 CLI 自身无法创建这类配置(Lark /schedule 与 CLI 均不支持前置条件,Dashboard 请求需要 .dashboard-secret)。补充一点:.dashboard-secret 是否可读取决于 workingDir ——实测 workingDir=~ 时它是 readWrite,而 ~ 是代码中多处的兜底值(如 command-handler.ts:478/483/826),只是当前常见配置指向具体子目录。

建议:配置时拒绝解析后落在 workingDir(或任何沙盒 readWrite 根)内的文件路径,并把 UI 示例改为仓库外的绝对路径。若希望彻底根治,可以把脚本快照存进 sidecar,代价是失去「文件修改下次触发生效」这一特性——这是个产品取舍,想先听听你的想法。

建议二:workingDir 为空时不是 fail-closed,而是落到 daemon 的当前目录

schedule-precondition-gate.ts:39-47 的注释写的是空目录「保持在既有的 fail-closed 路径上」,但实际行为不是:spawn 收到 cwd: '' 会继承 daemon 的当前工作目录。

端到端实测(真 gate + 真 runner):outcome=executed executed=1 wdPassedToRunner="" daemonCwd=/tmp —— 脚本照常执行,模型照常调用。

可达性:Dashboard 创建路由是 typeof b.workingDir === 'string' ? b.workingDir : process.cwd(),空字符串会被透传。

影响有界(只影响 inline 与绝对路径两种情形;相对路径 + 空 workingDirresolvedFilePath 里确实是 fail-closed),脚本内容本身仍是可信来源,所以这是执行目录的正确性问题而非提权。但注释与实际行为相反容易误导后续维护,建议要么让空值直接抛错,要么修正注释。

另外

hasSchedulePrecondition 目前只有测试引用,生产代码没有调用点,可以确认下是否为预留接口。


以上是自动评审的初步意见,其中的可达性判断(尤其建议一涉及的信任模型)包含推断成分,最终以维护者审阅为准。我们也没有做飞书真机验证,这部分以你 PR 描述中的 live 核验为准。

@deepcoldy

Copy link
Copy Markdown
Owner

补充更正上一条评论中「建议一」的可达性描述——上一条把缓解措施说窄了,这里更准确地重述,以免误导修复方向。

可达性(已核实):当前部署的 3 个沙盒 bot,workingDir 均为 ~/iserver.dashboard-secret 落在 deny-by-default 区,沙盒内的 CLI 读不到 HMAC secret、无法自签 dashboard 请求,因此文件型条件需要 owner 先行配置才谈得上被利用。

~ 是代码中多处的真实兜底(command-handler.ts:478/483/826daemon.ts:17187trigger-session.ts:187 均为 ?? '~')。当 workingDir~ 时,~/.botmux 会被 workingDir 的 readWrite 整体覆盖(fs-policy.ts:733;普通带 Lark transport 的会话不会把 ~/.botmux 冻成整根 deny,那是 no-transport 分支的行为),secret 因而可读写。实测最长前缀命中:

workingDir=~          → readWrite
workingDir=~/iserver  → deny-by-default
larkTransport=false   → deny

在这个前提下,沙盒内的 CLI 可以自签 dashboard IPC 自行创建带前置条件的任务;而且 inline 型(任意 bash、完全不碰文件)同样可创建,所以仅对文件路径做校验并不能堵住这条自造路径。上一条评论建议的路径校验只对「owner 误配相对路径」那一支有效,这点之前没说清楚。

需要明确的是:该 secret 暴露是 workingDir=~ 的既有问题,不是本 PR 引入的;本 PR 的影响是为这个既有暴露新增了一个「宿主机任意代码执行」原语。

因此建议拆成两件事

  1. 本 PR 内:对文件型条件做配置时的路径校验(拒绝解析后落在 workingDir 或任何沙盒 readWrite 根内),堵住 owner 误配这一支;同时把 UI 示例从 scripts/check-ready.sh 改为仓库外的绝对路径。
  2. workingDir=~ 下的 secret 暴露作为独立加固项另行讨论,不阻断本 PR

严重度维持原判(非阻断):当前 fleet 下自造路径不可达;文件型需要 owner 主动配置;而 workingDir=~ 的场景里沙盒本身已经很宽(整个 home readWrite,含 sibling BOT_HOME 与凭据),前置条件只是边际上多加一个沙盒外执行原语。

同样地,以上仍是自动评审的初步意见,最终以维护者审阅为准。

@anarkh anarkh changed the title feat(scheduler): 支持定时任务 Bash 前置条件与执行日志 feat(scheduler): 支持定时任务 Bash 前置条件、执行日志与多群绑定 Sep 2, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

第四轮自动评审(基线同步到 origin/master=ae72b2c05,本地 rebase 零冲突;bun run build 通过,19 个相关测试文件 703 passed / 1 skipped)。

先说这轮修好的

daemon.tsmodel_dispatch_error 补上了 error: error.message——此前模型派发失败只有错误码、没有任何消息,现在日志弹窗里能看到失败原因了。这正面解决了我们上一轮提的可观测性问题的一半。变异验证:把这个字段去掉 → 7 条测试红,守卫是扎实的。另外确认该错误文本只经认证的 /api/schedules/<id>/logs 返回,不在公开只读路径、也不进公开投影,没有泄漏面。

建议一:fix(scheduler): 等待前置条件输出流结束 是正确的修复,但缺测试

这条我们一开始判断错了,先更正:最初以为「Bash 子进程的 close 总在各输出流 end 之后触发,所以新增的三个 end 等待是冗余的」。实测这个前提在 Bun 1.4.0 上不成立

Bun 1.4.0,脚本 printf "1\n",跑 25 轮的事件顺序统计:
  21x  fd3.end, err.end, out.end, close
   3x  out.end, fd3.end, err.end, close
   1x  out.end, err.end, close, fd3.end     ← close 早于 fd3.end

进一步做阳性对照量化后果,脚本 printf "1\n"; printf CTX >&3,跑 200 轮:

  • 保留这个改动:ok=200,丢失 prompt=0
  • 去掉三个 end 等待(即改动前的行为):ok=199,**丢失 prompt=1**,那一次 additionalPrompt 为空字符串

也就是说,改动前 FD 3 写入的追加 prompt 存在约 0.5% 概率被静默丢弃:门禁照常放行、模型照常调用,但追加的上下文没进去,且不产生任何错误。这个修复是承重的,不是多余的。

正因为它修的是一条真实存在的低频竞态,建议补一条回归测试(可以通过注入 stdio 桩、让 close 先于 FD3 的 end 触发来确定性复现),否则以后很容易被当作冗余代码删掉——我们自己一开始就差点这么建议。另外这条 commit 的 message 正文是空的,如果能写清当初观察到的现象,会很有帮助。

顺带一个观察:同一 commit 里新增的 child.pid === undefined 分支,在「cwd 不存在」这条路径上是冗余的——单独去掉它行为不变,真正起作用的是提前注册的 child.once('error') 处理器(把两者一起去掉才会抛出未捕获的 ENOENT)。如果它是为别的路径准备的,建议补一句注释说明。

建议二:多群聊扇出没有并发上限

executeScheduledTaskForTargetsPromise.allSettled 一次性派发所有目标,实测 200 个目标 → 峰值并发 200,没有任何节流。下游也不兜底:withBotTurnAdmission 是互斥门而非并发限流器,withActiveSessionKeyLock 按 chatId 分键因而不同群之间不串行。

同一文件里的角色批量操作是有上限的(MAX_ROLE_BATCH_CHAT_IDS),这里可以对标。除了可能触发飞书侧限流外,更需要留意的是 daemon 侧同时拉起大量 CLI 进程对宿主内存的压力。建议加一个并发上限(例如 20–50)或分批派发。

建议三:群列表加载失败时没有兜底输入

这轮移除了手动输入群聊 ID 的入口,改为只能从加载出的群列表勾选。后端仍然接受任意 chatId,所以这是 UI 侧的能力收窄。问题在于加载失败时(schedules-page.tsxchatLoadError 分支)只显示一行错误提示,选择器区域既没有可勾选项也没有手动输入,此时表单无法完成绑定。

好的一面是:已绑定但不在列表中的 chat 会以 retained 形式保留并有测试覆盖,编辑存量任务不会丢绑定。建议仅在加载失败时保留手动输入作为兜底。

仍然待确认:前两轮提的两点

这两点从第一轮至今四轮都是 0 行改动,想确认是有意保留还是排期未到:

  1. 相对路径的文件型条件resolvedFilePath 不校验解析结果落在哪里,相对路径以任务 workingDir 为基准,而该目录对沙盒内的 CLI 是可写的;脚本执行时是宿主机 /bin/bash 且不套沙盒。UI 占位符目前仍推荐 scripts/check-ready.sh 这种仓库内相对路径。(可达性与「AI 自造配置」那部分的更正见我们上一条评论。)
  2. workingDir 为空时不是 fail-closedschedule-precondition-gate.ts 的注释写着保持 fail-closed,实际 spawn 收到 cwd: '' 会继承 daemon 的当前目录,脚本照常执行。建议要么让空值直接报错,要么修正注释。

另外,列表侧的失败可见性(lastStatus 徽标)目前仍是 0 引用,日志弹窗之外看不到某个任务在报错——如果这是有意的取舍也没问题,确认一下即可。


以上是自动评审的初步意见,最终以维护者审阅为准。这轮我们没有做飞书真机验证,该部分以你 PR 描述中的记录为准。

@anarkh
anarkh force-pushed the feat/schedule-bash-precondition branch from 6931595 to 992e6c3 Compare September 4, 2026 09:39
@deepcoldy
deepcoldy merged commit e834388 into master Sep 6, 2026
7 of 9 checks passed
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

🚀 Released in v3.19.1

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