定时任务与调度
Claude Code 如何安排重复提示和一次性提示、管理分布式锁以及通过确定性抖动在整个队列中分散负载。
Claude Code 有一个内置的 cron 风格的调度程序,可以让模型队列提示未来的执行——可以在特定时间执行一次,也可以按定期计划执行。 整个系统是围绕单个 JSON 文件构建的(.claude/scheduled_tasks.json)以及在运行的 REPL 内每秒进行一次的轮询循环。
utils/cronScheduler.ts → utils/cronTasks.ts →
utils/cronJitterConfig.ts → tools/ScheduleCronTool/ →
hooks/useScheduledTasks.ts
三个面向用户的工具驱动系统: CronCreate, CronDelete, 和 CronList。 在它们后面是一个非 React 调度器核心(cronScheduler.ts) 由交互式 REPL 和无头 SDK 守护程序共享,通过连接到 REPL useScheduledTasks 反应钩子。
Tools
CronCreate / Delete / List — 面向模型的 API、验证、文件 I/O
调度器核心
cronScheduler.ts — 1s 滴答循环、chokidar 文件观察器、锁定、抖动
Storage
.claude/scheduled_tasks.json + 用于临时任务的内存会话存储
反应胶
useScheduledTasks — 在 REPL 中安装调度程序,将触发路由到消息队列
舰队行动杆
cronJitterConfig.ts — GrowthBook 支持的调优无需重启即可上线
每个计划任务都由一个表示 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
}
durable 标志从不接触磁盘: writeCronTasks() 用解构扩展剥离它({ durable: _durable, ...rest })。 根据定义,存储在文件中的所有内容都是持久的,因此该标志仅在运行时有意义。
存在两种任务类型,具体取决于 recurring:
调度程序在每个 REPL 会话中创建一次 createCronScheduler() 并通过一个简单的管理 { start, stop, getNextFireTime } 界面。 生命周期有一个故意的延迟启用设计,以避免加载 chokidar 和文件系统机制,直到任务实际存在。
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)开始计算的,因此您不会快速追赶触发。
用户可以在同一项目目录中同时运行多个 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)
}
}
当数百万用户同时(“每小时”、“上午 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
}
:00 and :30 感到不安,因为这是人类真正选择的唯一几分钟。
默认抖动配置值
深入探讨 - 通过 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 依赖链的影响。
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循环。
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
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 计费标头,与交互式请求相比,自动后台请求的服务质量较低。
agentId 但是队友消失了(终止或从未存在过),钩子会立即删除孤立的 cron,而不是让它每次都无处发射。 无论如何,一次性都会自我删除,但重复的 crons 将无限期循环,直到 7 天自动到期。
如果计划触发任务时 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))
这确保了提示包含 ``` 无法提前关闭周围的围栏并将后续文本暴露为无人看守的指令。
调度系统有多个可独立操作的门:
feature('AGENT_TRIGGERS')
通过 Bun 消除死代码。 整个 cron 模块从禁用触发器的构建中剥离。
CLAUDE_CODE_DISABLE_CRON=1
胜过 GrowthBook 的本地覆盖。 终止所有调度,包括下一次轮询时已运行的调度程序。
tengu_kairos_cron
全舰队的终止开关。 每 5 分钟轮询一次; 默认为 true,因此 Bedrock/Vertex/DISABLE_TELEMETRY 用户可以获得完整的 cron。
tengu_kairos_cron_durable
更窄的门 - 仅终止磁盘持久性。 仅会话 cron 保持活动状态。 默认为真。
tengu_kairos_cron_config
无需部署即可进行抖动调整的操作杆。 60 秒内汇聚舰队。
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标记系统助理任务不受此限制。
知识检查
durable: false) cron 任务存储?check() 重新安排重复任务 now 而不是根据计算出的开火时间?:00 or :30 分钟?