Skip to content

feat(scheduler): 定时任务时区支持 dashboard 配置覆盖(默认跟随主机时区,含 #398)#399

Merged
deepcoldy merged 6 commits into
masterfrom
feat/scheduler-configurable-timezone
Jul 7, 2026
Merged

feat(scheduler): 定时任务时区支持 dashboard 配置覆盖(默认跟随主机时区,含 #398)#399
deepcoldy merged 6 commits into
masterfrom
feat/scheduler-configurable-timezone

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景

这是在 #398(把 cron 触发/各处显示从写死 Asia/Shanghai 统一改为「主机本地时区」)基础上叠加的增强,已 merge #398 到本分支,所以本 PR 的 diff = #398 的改动 + 本次增量。合并策略见文末。

为什么需要「可配置」

#398 的默认是「跟随主机系统本地时区」。但在我们这台生产 daemon 机上,/etc/localtime → America/Los_Angeles/etc/timezone 文件陈旧地写着 Asia/Shanghai,Node 不读它),Intl 解析出来是 LA 而不是 +8。若直接按主机时区,现网 6 个「每天X点」重复任务会从北京时间静默漂到 LA 时间(约 15h)。

结论(申晗拍板):默认仍跟主机时区(没别的可靠办法确定用户时区),但允许在 dashboard 显式配置覆盖——主机时区被误配/不是用户想要的时区时,填一个 IANA 名即可。

改动

  1. utils/timezone.tsscheduleTimeZone() 解析优先级env(BOTMUX_SCHEDULE_TIMEZONE) → dashboard 配置(~/.botmux/config.jsonscheduleTimeZone) → 主机本地时区。每层经 isValidTimeZone 校验,非法值跳过下一层,最终仍兜底 Etc/Unknown→UTC
  2. 一次性「明天HH:MM」对齐同一时区:原来走 setHours()=主机本地(fix(scheduler): 用户定时任务统一用系统本地时区(不再写死东8区) #398 没改),一旦配置 ≠ 主机时区,cron 与 one-shot 又各跑各的——正是 fix(scheduler): 用户定时任务统一用系统本地时区(不再写死东8区) #398 想修的矛盾会复活。改用 tz 感知的 zonedTomorrowAt() 按解析出的时区换算 UTC 瞬时,彻底统一。
  3. dashboard Settings 新增「定时任务 · 时区」:常用时区 datalist、留空=跟随主机(占位符显示当前主机时区)、非法值拒绝并回显。复用现成的 ~/.botmux/config.json overlay + Settings 页机制(与 publicReadOnly/chatBotDiscovery 同款,2s 缓存热生效免重启)。
  4. 配置读写global-configscheduleTimeZone 字段(宽松存、写入时严格校验);settings-write-applier 增校验(invalid_scheduleTimeZone)+清除(空/null→删键回落主机);resolveDashboardSettings 回显 scheduleTimeZone(配置值) + hostTimeZone(主机自动探测)。

影响范围评估

  • 仅动用户定时任务子系统(scheduler + 其显示/预览 + dashboard settings)。
  • 默认行为不变:无 env、无配置 ⇒ 跟随主机时区(= fix(scheduler): 用户定时任务统一用系统本地时区(不再写死东8区) #398 的行为);现网只要不配 dashboard 即零回归;配了才改。
  • 维护窗口 maintenance-schedule.tsMAINTENANCE_TZ 是独立子系统,刻意不改
  • 跨进程:CLI(botmux schedule)、daemon scheduler、dashboard 三个进程都读同一份 config.json → 一致。
  • 无 IANA 校验旁路:croner / toLocaleString / one-shot 换算全部经 isValidTimeZone/归一,非法值一律回退。

⚠️ 上线必做

默认=主机=LA。上线后必须在 dashboard 把时区设为 Asia/Shanghai(或给 daemon 设 TZ=Asia/Shanghai),否则现网重复任务会漂到 LA。我倾向 dashboard 配置(长期正解、不依赖 OS)。

测试

  • test/timezone.test.ts:tz 数学(zonedWallClockToUtc/zonedTomorrowAt,含 LA 冬/夏令时)、isValidTimeZonescheduleTimeZone env→config→host 优先级(HOME 重指向真实读 config.json)。
  • test/scheduler.test.ts:「明天X点」在显式时区下落对 wall-clock(host-independent 回归守卫)。
  • test/settings-write-applier.test.ts:有效持久化 / 非法拒绝 / 空/null 清除。
  • test/settings-timezone-ui.test.ts(新增):React 渲染 + 输入/blur/清除交互。
  • 受影响套件(timezone/scheduler/settings-write-applier/settings-timezone-ui/schedule-card-model/command-handler/maintenance)共 343 全绿tsc + dashboard bundle 通过。
  • Runtime harness(跑 built dist):配置=Asia/Shanghai 时,本 LA 主机上 明天9:00 → 北京 09:00每天11:00 → 北京 11:00,两者一致落北京。
  • 全量 pnpm test:新增用例零回归;剩余 failing 均为本机预存环境项(terminal-url×7 外部 host、recall-frozen×1、dashboard-create-session×3 / prompt-builder 依赖 daemon registry),已 stash 对照确认与本改动无关。

UI 截图

(下方评论附)dashboard Settings「定时任务 · 时区」:配置覆盖态 vs 留空跟随主机态。

合并策略(请 reviewer/申晗定)

本分支已含 #398。二选一:(a) 直接合本 PR(= #398+增量一起落,随后关 #398,历史里保留 @xu4wang 的 commit);(b) 先合 #398 到 master,本分支 rebase 后只剩增量 diff。我推荐 (a),一次合完。

🤖 Generated with Claude Code

xu4wang and others added 6 commits July 7, 2026 18:55
问题:重复类定时任务(每天/每周/每月/工作日/每N小时分钟/裸 cron)转成 cron 后,
computeNextRun 用写死的 `timezone:'Asia/Shanghai'` 触发;而一次性「明天HH:MM」用
Date.setHours() 走系统本地时间。非 +8 主机上两者错开一个时差,即用户报的“有时本地、
有时东8”。

改动:新增 src/utils/timezone.ts 的 scheduleTimeZone()(取系统本地 IANA 时区),把 scheduler
触发/grace/显示、schedule-card-model 的 dashboard next-run 默认时区、command-handler 卡片显示、
cli 列表显示 共 6 处写死的 'Asia/Shanghai' 全替换成它。触发与所有显示/预览从此跟随系统本地,
与一次性解析一致。

健壮性:normalizeScheduleTimeZone() 对解析失败的哨兵('Etc/Unknown'/空,某些 TZ='' 主机会出现)
回退 UTC——否则 croner/toLocaleString 抛错,会让 cron next-run 变 null、定时任务列表渲染崩
(codex review 指出并已修+测)。

不动:维护窗口(maintenance-schedule.ts 的 MAINTENANCE_TZ)是独立子系统,本次按需求不改。

测试:scheduler/schedule-card-model 新增机器无关用例(断言 cron next-run 落本地整点);
timezone.test.ts 覆盖 Etc/Unknown/空回退;受影响单测 310+ 全过;tsc 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#398(cron 触发/各处显示统一走主机本地时区)基础上叠加「可配置时区」:

- scheduleTimeZone() 解析优先级:env(BOTMUX_SCHEDULE_TIMEZONE) → dashboard 配置
  (~/.botmux/config.json 的 scheduleTimeZone) → 主机本地时区。每层经 isValidTimeZone
  校验,非法值跳过下一层,最终仍兜底 Etc/Unknown→UTC。
- 一次性「明天HH:MM」不再走 setHours() 主机本地,改用 tz 感知的 zonedTomorrowAt()
  按解析出的时区换算 UTC 瞬时,与 cron 触发/显示彻底统一(修掉 #398 描述里「有时本地
  有时东8」自相矛盾在 config≠host 时的残留)。
- dashboard Settings 新增「定时任务 · 时区」输入项:常用时区 datalist、留空=跟随主机
  (占位符显示当前主机时区)、非法值拒绝并回显错误。
- global-config 增 scheduleTimeZone 字段(宽松存、解析/写入时校验);settings-write-applier
  增校验(invalid_scheduleTimeZone)与清除(空/null→删键回落主机);resolveDashboardSettings
  回显 configured + hostTimeZone。

影响面:仅用户定时任务子系统。默认行为不变(无 env/无配置 ⇒ 跟随主机时区),配置为空时
现网零回归;维护窗口 MAINTENANCE_TZ 独立子系统,刻意不改。

测试:timezone(tz 数学/precedence)、scheduler(明天 tz 对齐)、settings-write-applier
(校验/清除)、settings-timezone-ui(React 渲染+交互) 全绿;runtime harness 用配置=Asia/Shanghai
在 LA 主机上验证 明天9:00 与 每天11:00 均落北京时间。
针对 PR #399 Codex 二审的 4 点:

1. [correctness] 时区切换后不重算已启用 cron 的 nextRunAt → 会按旧时区再触发一次。
   scheduler tick 检测 scheduleTimeZone() 变化,用纯函数 planCronRealign() 重算所有
   已启用 cron 的 nextRunAt(interval 相对周期/once 固定瞬时不受 tz 影响,跳过;
   computeNextRun 给未来occurrence,不会立即/重复触发)。
2. [correctness] zonedWallClockToUtc 在 DST spring-forward 不存在的墙钟时间会算到前一
   小时、与 croner 不一致。改为偏移修正 + round-trip 校验:gap 时回退到前向候选(= croner
   的前向对齐),fall-back 取首次出现。新增 LA 03-08 02:30→10:30Z、11-01 01:30→08:30Z 断言。
3. [consistency] Web dashboard schedule/overview 时间显示用浏览器本地时区而非 scheduleTimeZone。
   /api/schedules 回传 effective timezone → store 携带 → schedules-page/overview-page 用
   `toLocaleString(undefined, { timeZone, timeZoneName:'short' })` 渲染(空则回落浏览器本地)。
4. [scope] 飞书 Settings 卡片不暴露该项——本 PR 只承诺 Web Settings,移除 src/i18n 里对应
   的 dead key(Web 用 dashboard/web/i18n.ts;飞书卡片时区设置留作后续)。
   另:ISO 时间戳路径保留 JS 本地解析语义,加注释说明(无 offset 的 ISO 不重解释)。

测试:新增 planCronRealign(recompute/跳过 interval-once-disabled/ownership/幂等)、DST
gap+fall-back、fmtScheduleDate 按指定时区渲染;受影响 8 套件全绿;全量 pnpm test 零新增
回归(剩余 failing 均本机预存环境项)。
针对 0c4b2a1 复核的 3 点:

1. [DST 通用性] zonedWallClockToUtc 手写偏移法只对负 offset 时区(LA)正确,正 offset
   (Europe/Berlin 等)的 gap/fall-back 仍与 croner 不一致。改为**直接用 croner 求解**一次性
   「明天HH:MM」:把它表达成「指定日+月」的 cron 取 nextRun —— 与 cron 任务同一引擎,DST
   gap(前向)/fall-back(取首次)语义对正负 offset 都天然一致。删除易错的手写 tzOffsetMs/
   zonedWallClockToUtc。新增 Berlin gap(03-29 02:30→01:30Z)/fall-back(10-25 02:30→00:30Z) 与
   croner 逐一对齐的断言。
2. [事件传播] tz realign 只写 schedule-store,dashboard aggregator / 已打开 Web tab 收不到。
   - realign 每个改动的 task 显式 `dashboardEventBus.publish('schedule.updated', {nextRunAt})`
     (复用 setEnabled/toggleDelivery 的既有模式),aggregator + SSE + Web store 都会更新。
   - 新增 `schedule.timezone` 事件:tz 变化时广播 effective tz → Web store 更新 scheduleTimeZone
     → 已打开的 schedules/overview tab 无需刷新即重渲染。daemon /api/events → subscribeDaemon →
     aggregator(pass-through)→ /events 全链路无 allowlist,自动透传。
   - Settings 保存成功后同步 `store.setScheduleTimeZone(effective)`,本 SPA 会话内即时生效。
3. [停机边界] 停机期间改 config/env 的 stale nextRunAt:startScheduler 新增「future-dated cron
   重算」(幂等——tz 未变时 computeNextRun 给同一 occurrence;过期任务留给 tick catch-up 不丢);
   并初始化 lastTickTz,避免首 tick 重复重算。

测试:Berlin/LA DST 与 croner 对齐、planCronRealign 幂等/ownership/future-only(startup 契约);
受影响 8 套件 350 全绿;E2E harness 复验 croner 版一次性仍正确;全量零新增回归(listen-with-probe
单跑绿,为满负载端口 flake)。
Codex 三审剩的 1 点:`BOTMUX_SCHEDULE_TIMEZONE` 存在时,前端把「effective」错误地重建为
`configured || host`,忽略 env 覆盖 —— 保存任意 Settings 后当前 SPA 的 schedule 显示时区会被
改成 config/host(而 daemon 看 effective 没变,不会再发 schedule.timezone 修正)。

修法:后端在 resolveDashboardSettings / PUT 响应里显式返回 `effectiveScheduleTimeZone =
scheduleTimeZone()`(env → config → host 的真实值)。前端一律用它,不再自行推断:
- parseSettings 读 effectiveScheduleTimeZone(旧后端缺失时回退 configured||host)。
- 保存成功后 `store.setScheduleTimeZone(saved.effectiveScheduleTimeZone)`。
- TimeZoneRow 的「当前生效」提示改用传入的 effective(不再 value||host)。

/api/schedules 的 timezone 与 /api/settings 的 effectiveScheduleTimeZone 同源(都 scheduleTimeZone()),
初始显示与保存后一致,env 覆盖下也正确。

测试:settings-timezone-ui 新增 env 覆盖用例(configured 空、host LA,但 effective=Tokyo→提示显示
Tokyo);受影响 5 套件 161 全绿;全量零新增回归(4 个 failing 文件均本机预存基线)。
@deepcoldy deepcoldy merged commit e5fa79f into master Jul 7, 2026
1 check passed
@deepcoldy deepcoldy deleted the feat/scheduler-configurable-timezone branch July 7, 2026 19:15
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