Claude Code 源码分析第 43 课 · 第 06
第 43 课

定时任务与调度

Claude Code 如何安排重复提示和一次性提示、管理分布式锁以及通过确定性抖动在整个队列中分散负载。

01 Overview

Claude Code 有一个内置的 cron 风格的调度程序,可以让模型队列提示未来的执行——可以在特定时间执行一次,也可以按定期计划执行。 整个系统是围绕单个 JSON 文件构建的(.claude/scheduled_tasks.json)以及在运行的 REPL 内每秒进行一次的轮询循环。

覆盖源文件
utils/cronScheduler.tsutils/cronTasks.tsutils/cronJitterConfig.tstools/ScheduleCronTool/hooks/useScheduledTasks.ts

三个面向用户的工具驱动系统: CronCreate, CronDelete, 和 CronList。 在它们后面是一个非 React 调度器核心(cronScheduler.ts) 由交互式 REPL 和无头 SDK 守护程序共享,通过连接到 REPL useScheduledTasks 反应钩子。

第 1 层

Tools

CronCreate / Delete / List — 面向模型的 API、验证、文件 I/O

第 2 层

调度器核心

cronScheduler.ts — 1s 滴答循环、chokidar 文件观察器、锁定、抖动

第 3 层

Storage

.claude/scheduled_tasks.json + 用于临时任务的内存会话存储

第 4 层

反应胶

useScheduledTasks — 在 REPL 中安装调度程序,将触发路由到消息队列

第 5 层

舰队行动杆

cronJitterConfig.ts — GrowthBook 支持的调优无需重启即可上线

02 CronTask 数据模型

每个计划任务都由一个表示 CronTask 目的。 磁盘形状有意最小化——仅运行时字段在写入之前被剥离。

// utils/cronTasks.ts
export type CronTask = {
  id:        string         // 8-hex UUID slice — enough for MAX_JOBS=50
  cron:      string         // 5-field cron in LOCAL time
  prompt:    string         // what to enqueue when the task fires
  createdAt: number         // epoch ms — anchor for missed-task detection

  lastFiredAt?: number     // written back after each recurring fire
  recurring?:   boolean    // true = reschedule; false/undefined = delete on fire
  permanent?:   boolean    // exempt from recurringMaxAgeMs auto-expiry

  // Runtime-only — never written to disk:
  durable?:  boolean       // false = session-scoped (in-memory only)
  agentId?:  string        // routes fires to a specific in-process teammate
}
设计洞察
The durable 标志从不接触磁盘: writeCronTasks() 用解构扩展剥离它({ durable: _durable, ...rest })。 根据定义,存储在文件中的所有内容都是持久的,因此该标志仅在运行时有意义。

存在两种任务类型,具体取决于 recurring:

一次性(重复:错误)
触发一次 → 自动删除
重复出现(重复出现:true)
火灾→从现在开始重新安排→7天后自动过期
耐用(耐用:真实)
保存到 .claude/scheduled_tasks.json
仅会话(持久: false)
bootstrap/state.ts 内存; 模具与工艺
03 调度程序生命周期

调度程序在每个 REPL 会话中创建一次 createCronScheduler() 并通过一个简单的管理 { start, stop, getNextFireTime } 界面。 生命周期有一个故意的延迟启用设计,以避免加载 chokidar 和文件系统机制,直到任务实际存在。

stateDiagram-v2 [*] --> Polling: start() — no tasks yet Polling --> Enabling: getScheduledTasksEnabled() flips true\n(CronCreate ran or file already has tasks) Enabling --> Running: enable() acquires lock + starts chokidar + setInterval(check, 1000ms) Running --> Running: check() fires every 1s Running --> Running: chokidar detects file change → load(false) Running --> [*]: stop() clears timers, releases lock, closes watcher note right of Polling enablePoll probes every 1s. Timer is unref'd so -p mode can still exit. end note note right of Running isOwner=true: processes file tasks. isOwner=false: probes lock every 5s. Session tasks bypass lock entirely. end note

The check() 内循环

每一秒, check() 从引导状态迭代所有加载的文件任务(如果是锁所有者)和所有会话任务。 对于每个任务:

// cronScheduler.ts — simplified check() inner loop
function process(t: CronTask, isSession: boolean) {
  let next = nextFireAt.get(t.id)
  if (next === undefined) {
    // First sight: anchor from lastFiredAt (if previously fired) or createdAt.
    // Anchoring from lastFiredAt prevents "stale spawn" re-firing every cycle.
    next = t.recurring
      ? jitteredNextCronRunMs(t.cron, t.lastFiredAt ?? t.createdAt, t.id, jitterCfg)
      : oneShotJitteredNextCronRunMs(t.cron, t.createdAt, t.id, jitterCfg)
    nextFireAt.set(t.id, next ?? Infinity)
  }
  if (now < next) return  // not yet

  // Fire!
  onFireTask ? onFireTask(t) : onFire(t.prompt)

  if (t.recurring && !aged) {
    // Reschedule from now — not from next — to avoid rapid catch-up after blocking.
    nextFireAt.set(t.id, jitteredNextCronRunMs(t.cron, now, t.id, jitterCfg))
    if (!isSession) firedFileRecurring.push(t.id) // batch lastFiredAt write
  } else {
    // One-shot or aged recurring: remove from store / file.
    isSession ? removeSessionCronTasks([t.id]) : removeCronTasks([t.id])
  }
}
预防追赶
重复性任务总是重新安排 now,不是来自计算的开火时间。 如果会话被长查询阻塞,并且上午 9 点的任务直到 9:05 才触发,则下一次触发是从 9:05(而不是 9:00)开始计算的,因此您不会快速追赶触发。
04 多会话分布式锁

用户可以在同一项目目录中同时运行多个 Claude 会话。 如果没有协调,两个会话都会触发相同的磁盘任务——重复工作。 Claude Code 通过每个项目解决了这个问题 调度程序锁.

// cronScheduler.ts — lock acquisition in enable()
isOwner = await tryAcquireSchedulerLock(lockOpts).catch(() => false)

if (!isOwner) {
  // Non-owner: probe for lock takeover every 5s.
  // Coarse because takeover only matters when the owning session crashes.
  lockProbeTimer = setInterval(() => {
    tryAcquireSchedulerLock(lockOpts).then(owned => {
      if (owned) { isOwner = true; clearInterval(lockProbeTimer) }
    })
  }, 5000) // LOCK_PROBE_INTERVAL_MS
  lockProbeTimer.unref?.()
}

该锁由 PID 进行活性探测。 如果拥有进程在没有调用的情况下死亡 stop(),非拥有会话将在下一个 5 秒探测中检测到陈旧的锁并接管。

会话任务是免锁的
仅会话任务(durable: false)位于进程私有内存中,因此不存在共享文件,也没有双重风险。 锁保护仅适用于文件支持的任务。 该代码通过显式强制执行此操作 if (isOwner) 围绕文件任务处理的门,后面是会话任务的无条件块。

The stop() 方法总是释放锁:

// cronScheduler.ts
stop() {
  stopped = true
  clearInterval(checkTimer)
  clearInterval(lockProbeTimer)
  void watcher?.close()
  if (isOwner) {
    isOwner = false
    void releaseSchedulerLock(lockOpts)
  }
}
05 负载传播抖动

当数百万用户同时(“每小时”、“上午 9 点”)安排任务时,他们都会同时生成推理请求 - 惊群效应。 Claude Code 添加了确定性的每任务抖动,以将这些峰值分散到整个队列中。

抖动量源自任务 ID(8 十六进制字符 UUID 切片):

// cronTasks.ts — stable per-task fraction in [0, 1)
function jitterFrac(taskId: string): number {
  const frac = parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000
  return Number.isFinite(frac) ? frac : 0
}

该比例在重新启动后保持稳定(相同的任务 ID = 相同的抖动),均匀分布在整个队列中,并且不需要协调。 根据任务类型应用两种策略:

重复任务——前向抖动

// Forward jitter: fires up to recurringFrac * interval late (cap: recurringCapMs)
export function jitteredNextCronRunMs(cron, fromMs, taskId, cfg): number | null {
  const t1 = nextCronRunMs(cron, fromMs)   // next fire
  const t2 = nextCronRunMs(cron, t1)        // one-after (for interval))
  if (t2 === null) return t1               // pinned date — no herd risk
  const jitter = Math.min(
    jitterFrac(taskId) * cfg.recurringFrac * (t2 - t1),
    cfg.recurringCapMs,
  )
  return t1 + jitter
  // e.g. hourly at cfg defaults (frac=0.1, cap=15min):
  // spread = jitterFrac(id) * 0.1 * 3600000ms = up to 360s = 6 min
}

一次性任务——后向抖动

// Backward jitter: fires up to oneShotMaxMs early, only on :00/:30 minutes
export function oneShotJitteredNextCronRunMs(cron, fromMs, taskId, cfg): number | null {
  const t1 = nextCronRunMs(cron, fromMs)
  // Only jitter "round" minutes — humans pick :00 and :30, bots don't.
  if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0) return t1
  const lead = cfg.oneShotFloorMs + jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs)
  return Math.max(t1 - lead, fromMs)  // never fire before creation
}
为什么要落后一击呢?
一次性任务由用户固定(“下午 3 点提醒我”)。 延误他们就违反了合同。 但开火几秒钟 early 对用户来说是不可见的,并且仍然会传播推理峰值。 仅有的 :00 and :30 感到不安,因为这是人类真正选择的唯一几分钟。

默认抖动配置值

recurringFrac
0.1(间隔的10%)
recurringCapMs
15 分钟(900,000 毫秒)
oneShotMaxMs
90年代初
oneShotMinuteMod
30(仅 :00 和 :30)
recurringMaxAgeMs
7 天(604,800,000 毫秒)
oneShotFloorMs
0(无最小导程)
深入探讨 - 通过 GrowthBook 进行实时操作调整

抖动配置源自 GrowthBook JSON 功能标志 (tengu_kairos_cron_config)而不是硬编码。 这意味着运维工程师可以在事件期间推动配置更改 - 例如,将一次性领先窗口从 90 秒扩大到 300 秒并传播 :00/:15/:30/:45 而不是仅仅 :00/:30 — 并且已经运行的 REPL 会话将在 60 秒内拾取它,而无需重新启动。

配置通过严格的 Zod 模式进行验证。 如果任何字段出界或 oneShotFloorMs > oneShotMaxMs 违反了不变性,整个配置回退到 DEFAULT_CRON_JITTER_CONFIG 而不是部分地信任它。

SDK 守护进程执行以下操作 not 使用 GrowthBook 进行抖动 — 它得到 DEFAULT_CRON_JITTER_CONFIG 直接地。 这使得调度程序包不受 GrowthBook 依赖链的影响。

06 三个 Cron 工具

CronCreate

该模型调用 CronCreate 带有 5 个字段的 cron 字符串、提示和可选 recurring and durable flags.

// CronCreateTool.ts — input schema
z.strictObject({
  cron:      z.string(),     // "*/5 * * * *" — 5-field local time
  prompt:    z.string(),     // what to run at fire time
  recurring: z.boolean().optional(),  // default: true
  durable:   z.boolean().optional(),  // default: false (session-only)
})

验证非常严格:将在接下来的 366 天中解析并检查 cron 表达式。 硬上限为 50 个职位 防止调度失控。 队友代理无法创建持久的 cron(他们的 agentId 在重新启动时将成为孤儿)。 创建成功后, setScheduledTasksEnabled(true) 翻转引导标志以立即启动enablePoll循环。

系统提示中的非分钟启发式
The CronCreate 系统提示明确指示模型避免调度 :00 or :30 除非用户指定了确切的时间。 “每天早上九点左右” → "57 8 * * *" or "3 9 * * *"。 这是 biggest 用于舰队减载的杠杆——抖动在模型选择的基础上最多增加几分钟。

CronDelete

获取作业 ID,验证其存在(如果在队友上下文中,则属于调用队友),然后调用 removeCronTasks([id])。 强制执行队友隔离:队友只能删除匹配的 crons agentId.

CronList

返回从磁盘和会话存储合并的所有任务。 队友只能看到自己的 cron; 团队领导看到了一切。 该工具已标记 isReadOnly() and isConcurrencySafe() - 它从不写入并且可以与其他工具调用一起运行。

// CronListTool.ts — teammate scoping
const ctx = getTeammateContext()
const tasks = ctx
  ? allTasks.filter(t => t.agentId === ctx.agentId)  // teammate sees own crons only
  : allTasks                                              // lead sees all
07 REPL 接线 — useScheduledTasks

The useScheduledTasks hook 是 React REPL 和非 React 调度程序核心之间的桥梁。 它只安装调度程序一次,并在卸载时将其拆除。

// hooks/useScheduledTasks.ts
export function useScheduledTasks({ isLoading, assistantMode, setMessages }: Props) {
  const isLoadingRef = useRef(isLoading)
  isLoadingRef.current = isLoading  // latest-value ref — no stale closure

  useEffect(() => {
    if (!isKairosCronEnabled()) return  // runtime gate

    const scheduler = createCronScheduler({
      onFire: prompt => enqueuePendingNotification({
        value: prompt, mode: 'prompt',
        priority: 'later',   // drains between turns — never interrupts
        isMeta: true,        // hidden from transcript UI
        workload: WORKLOAD_CRON, // lower QoS — no human waiting
      }),
      onFireTask: task => {
        if (task.agentId) {
          // Route to teammate agent instead of main REPL queue
          injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
          return
        }
        // Show "Running scheduled task (Mar 31 9:03am)" in transcript
        setMessages(prev => [...prev, createScheduledTaskFireMessage(...)])
        enqueueForLead(task.prompt)
      },
      isLoading: () => isLoadingRef.current,
      getJitterConfig: getCronJitterConfig,
      isKilled: () => !isKairosCronEnabled(),  // polled every tick — live killswitch
    })
    scheduler.start()
    return () => scheduler.stop()
  }, [assistantMode])
}

触发的提示排队于 'later' priority via enqueuePendingNotification。 REPL 的 useCommandQueue 在轮流之间耗尽此队列 — 因此 cron 任务永远不会中断活动查询。 这 WORKLOAD_CRON 归因流向 API 计费标头,与交互式请求相比,自动后台请求的服务质量较低。

孤儿队友 crons
当任务触发时 agentId 但是队友消失了(终止或从未存在过),钩子会立即删除孤立的 cron,而不是让它每次都无处发射。 无论如何,一次性都会自我删除,但重复的 crons 将无限期循环,直到 7 天自动到期。
08 错过的任务和启动赶上

如果计划触发任务时 Claude 没有运行,它会在启动时检测到这一点。 findMissedTasks() 计算每个任务的第一次触发时间 createdAt 并比较 Date.now():

// cronTasks.ts
export function findMissedTasks(tasks: CronTask[], nowMs: number): CronTask[] {
  return tasks.filter(t => {
    const next = nextCronRunMs(t.cron, t.createdAt)
    return next !== null && next < nowMs
  })
}

Missed one-shot 任务通过以下构建的通知提示呈现给用户 buildMissedTaskNotification()。 通知:

  • 包括人类可读形式的任务的 cron 表达式和创建时间戳。
  • 将每个提示包裹在代码围栏中,以防止意外的提示注入(使用比提示内运行的任何反引号长一个反引号的围栏)。
  • 明确指示模型 not 尚未执行 - 首先通过以下方式询问用户 AskUserQuestion.
  • 在模型看到通知之前从磁盘删除任务。

Missed recurring 任务是故意不浮现出来的。 调度程序的 check() 通过在第一个刻度上触发并从那里向前重新安排来正确处理它们。 对于那些只是逾期而不是错过的任务,显示它们会产生误导性的“克劳德没有运行时错过”的提示。

深入研究——错过任务通知中的提示注入防御

计划任务的提示可以包含任何字符串 - 包括反引号序列,这些序列将关闭 Markdown 代码围栏并允许外部指导文本被误读为可执行指令。

buildMissedTaskNotification() 通过 CommonMark 的栅栏长度规则来防御此问题:栅栏只能由等于或大于长度的栅栏关闭。 它在提示中找到最长的反引号,然后再用一个反引号打开栅栏:

const longestRun = (t.prompt.match(/`+/g) ?? []).reduce(
  (max, run) => Math.max(max, run.length), 0
)
const fence = '`'.repeat(Math.max(3, longestRun + 1))

这确保了提示包含 ``` 无法提前关闭周围的围栏并将后续文本暴露为无人看守的指令。

09 端到端流程图
sequenceDiagram participant Model participant CronCreate participant cronTasks participant state as bootstrap/state participant scheduler as cronScheduler participant hook as useScheduledTasks participant queue as CommandQueue Model->>CronCreate: CronCreate({ cron, prompt, recurring, durable }) CronCreate->>CronCreate: validateInput — parse cron, check MAX_JOBS=50 alt durable=false (default) CronCreate->>state: addSessionCronTask(task) else durable=true CronCreate->>cronTasks: readCronTasks() → push → writeCronTasks() cronTasks-->>scheduler: chokidar 'change' event → load(false) end CronCreate->>state: setScheduledTasksEnabled(true) note over hook: enablePoll sees flag → enable() hook->>hook: tryAcquireSchedulerLock() hook->>hook: chokidar.watch(scheduled_tasks.json) hook->>hook: setInterval(check, 1000ms) loop every 1 second hook->>hook: check() — iterate file tasks (if owner) + session tasks alt task.nextFireAt <= now hook->>queue: enqueuePendingNotification(prompt, priority='later') alt recurring and not aged hook->>cronTasks: markCronTasksFired([id], now) else one-shot or aged hook->>cronTasks: removeCronTasks([id]) end end end queue-->>Model: REPL drains queue between turns
10 功能门和终止开关

调度系统有多个可独立操作的门:

Build-time

feature('AGENT_TRIGGERS')

通过 Bun 消除死代码。 整个 cron 模块从禁用触发器的构建中剥离。

运行时——环境变量

CLAUDE_CODE_DISABLE_CRON=1

胜过 GrowthBook 的本地覆盖。 终止所有调度,包括下一次轮询时已运行的调度程序。

运行时 — GrowthBook

tengu_kairos_cron

全舰队的终止开关。 每 5 分钟轮询一次; 默认为 true,因此 Bedrock/Vertex/DISABLE_TELEMETRY 用户可以获得完整的 cron。

运行时 — GrowthBook

tengu_kairos_cron_durable

更窄的门 - 仅终止磁盘持久性。 仅会话 cron 保持活动状态。 默认为真。

运行时 — GrowthBook JSON

tengu_kairos_cron_config

无需部署即可进行抖动调整的操作杆。 60 秒内汇聚舰队。

isKilled 每刻都会被轮询
The isKilled 回调在每个顶部进行检查 check() 称呼。 这意味着翻转 tengu_kairos_cron 在 GrowthBook 中设置为 false 会在 5 分钟内停止所有已运行的调度程序(其 GrowthBook 缓存刷新),而不仅仅是新会话。 这是事故发生时的“止血”机制。

要点

  • 任务存储在 .claude/scheduled_tasks.json; 仅会话任务位于 bootstrap/state.ts 内存,并且永远不会写入磁盘。
  • 调度程序以 1 秒的间隔进行轮询,但启用了延迟 — chokidar 和计时器在任务实际存在之前不会启动(setScheduledTasksEnabled(true)).
  • 当多个 Claude 会话共享工作目录时,每个项目的调度程序锁可防止双重触发。 非所有者每 5 秒探测一次,以便在所有者崩溃时接管。
  • 重复任务重新安排自 now (不是根据计算的触发时间)以避免在阻塞会话后快速赶上。
  • 每个任务 ID 的抖动都是确定性和稳定的 — 相同的任务 = 重新启动时传播的抖动相同。 重复性任务向前扩展(最多为间隔的 10%,上限为 15 分钟); :00/:30 的一次性任务向后传播(早至 90 年代)。
  • 抖动配置是一个实时的 GrowthBook 操作杠杆。 运维人员可以在事件期间扩大抖动,而无需重新启动任何客户端; 舰队在 60 秒内会合。
  • 启动时错过的一次性任务会出现防注入提示(自适应反引号栅栏),并在重新执行之前需要用户确认。
  • 重复任务在 7 天后自动过期,以防止会话生命周期无限增长; permanent: true 标记系统助理任务不受此限制。

知识检查

Q1. 仅会话在哪里(durable: false) cron 任务存储?
Q2. 为什么会 check() 重新安排重复任务 now 而不是根据计算出的开火时间?
Q3. Claude Code 一次允许的最大计划作业数是多少?
Q4. 为什么单次向后抖动仅适用于登陆的任务 :00 or :30 分钟?
Q5. 调度程序如何防止同一目录中的两个 Claude 会话触发同一任务两次?
0/5