scheduled-tasks:不是后台守护线程,而是会话内的 Cron Prompt 调度器
对应官方文档:claude-code-docs/docs/scheduled-tasks.md 里的 Run prompts on a schedule。
一句话讲清楚
scheduled-tasks 的本质,并不是 Claude Code 背后运行着一个神秘的“定时执行服务”,也不是 /loop 命令本身具备某种特有的原子能力。
它真正实现的是一套 Prompt 延迟注入系统。它将“未来某个时间点需要执行的任务”记录下来,当时间点到达且当前会话处于空闲(Idle)状态时,将预设的 Prompt 重新“喂”给 Claude 的 Query Loop。
你可以把它看作是一个会话级的“闹钟”,到点后会自动帮你按一下键盘。它的核心逻辑围绕以下三个原子工具展开:
- CronCreate:任务登记。
- CronList:任务展示(合并内存与磁盘)。
- CronDelete:任务清理。
根据任务的生命周期,它分为两类存储形态:
- Session-only:仅存于当前进程内存,随进程退出而消失。
- Durable(持久化):写入项目目录下的
<project>/.claude/scheduled_tasks.json,支持跨会话重启。
实现机制
虽然 /loop 是最常见的入口,但 claude-code-opensource/src/skills/bundled/loop.ts 仅仅是一个封装。它负责将自然语言(如 "every 5m")解析为 Cron 表达式,调用 CronCreate 工具进行登记,并立即触发第一次执行。
真正支撑起这一整套调度机制的,是以下几个核心模块:
1. 任务的“等级制度”与校验
在 claude-code-opensource/src/tools/ScheduleCronTool/CronCreateTool.ts 中,系统对任务创建设定了严格的限制:
- 同一项目下最多允许 50 个任务。
- Teammate(子代理)不允许创建 Durable 任务。这是因为子代理本身的生命周期不跨会话,持久化任务在重启后会成为无主孤儿。
- 只有校验通过的任务才会触发
scheduledTasksEnabled状态位,进而唤醒调度引擎。
2. 双速存储与并发锁
任务存储在 claude-code-opensource/src/utils/cronTasks.ts 中。由于支持持久化任务,为了避免在同一目录下开启多个 Claude 会话(例如在两个终端窗口同时运行)导致任务被多次触发,系统引入了 claude-code-opensource/src/utils/cronTasksLock.ts。 它通过 .claude/scheduled_tasks.lock 文件的原子创建和 PID 活跃检查,确保同一时间只有一个 Master Session 负责调度磁盘上的持久化任务。
3. Idle-only 注入策略
调度核心位于 claude-code-opensource/src/utils/cronScheduler.ts。不同于后台线程直接在后台乱跑,它的触发是非常“懂礼貌”的:
- 它会每秒轮询一次到期任务。
- 必须等 Claude 当前不忙时(即没有正在执行的任务或等待用户输入的状态),才会将任务注入队列。
- 注入的 Prompt 会被标记为
priority: 'later'和isMeta: true。主会话会先发一条scheduled_task_fire的系统提示,随后才正式运行任务。
4. 确定性抖动(Deterministic Jitter)
为了防止大规模客户端在整点(如 9:00)同时向 API 发送请求,cronTasks.ts 实现了一种基于任务 ID 哈希的抖动机制:
- 周期性任务(Recurring)会在触发点后延后约 10% 的周期时长(上限 15 分钟)。
- 一次性任务(One-shot)如果恰好落在整点/半点,则会随机提前约 90 秒触发。
5. 错过任务的“先问后做”
当 Claude 重启并加载持久化任务时,如果发现某个 One-shot 任务在离线期间已经到期,buildMissedTaskNotification(...) 会构造一个专门的通知,询问用户是否现在补跑,而不是武断地直接执行。这种设计极大地降低了陈旧指令带来的意外风险。
6. 云端调度与远程代理(Cloud Scheduling)
对于需要在本地 CLI 关闭后依然保持运行的任务,Claude 提供了一套远程触发机制(Remote Triggers):
- CCR (Claude Cloud Runtime):不同于本地 Cron,远程任务是在 Anthropic 的云端基础设施中独立运行的。每个触发器都会启动一个完全隔离的沙盒环境。
- Environment ID:远程调度需要绑定一个云端环境 ID。通过
/schedule命令,用户可以配置在特定时间点启动一个远程 Agent 实例。 - 协作调度:本地持久化任务(Durable Tasks)可以通过
RemoteTriggerTool与云端协作。例如,本地任务可以在完成数据处理后,通过调用远程触发器启动一个持续 1 小时的云端监控任务,而无需保持本地终端开启。 - 限制:云端调度的最小触发间隔通常为 1 小时,且由于运行在隔离沙盒中,远程 Agent 无法直接访问用户的本地非 Git 管理文件。
别踩这些坑
- 非独立 Worker:对于本地任务,如果你的终端关了,所有的定时任务都会停摆(Session-only 直接丢失,Durable 需等下次启动)。
- 云端持久性:远程触发器(Remote Triggers)是真正的“云端 Durable”,即使本地关机,它们也会在云端按时触发。
- 非精确调度:由于受到 Idle-only 策略和 Jitter 抖动的影响,本地任务的触发时间通常会有秒级到分钟级的偏差。
- Teammate 限制:子代理创建的任务不能持久化,只能随该子代理的消亡而消亡。
- 时区行为:本地 Cron 按本地时区解析;而远程云端调度通常使用 5 字段的标准 Cron 表达式并遵循 UTC 时间。
继续探索
- 如果你想了解定时任务如何将输出“悄无声息”地塞回主循环,请看 Query Loop 和 Message Queue 的处理逻辑。
- 关于子代理如何管理自己的私有任务,请参考 Subagent 架构:Teammate 的自治与隔离。
- 如果你想深入研究远程 Agent 的环境隔离,请看 远程执行:CCR 与沙盒安全。
源码锚点
claude-code-opensource/src/skills/bundled/loop.ts:/loop命令的前门逻辑。
📄 src/skills/bundled/loop.ts — `/loop` 命令的前门逻辑。
const DEFAULT_INTERVAL = '10m'
const USAGE_MESSAGE = `Usage: /loop [interval] <prompt>
Run a prompt or slash command on a recurring interval.
Intervals: Ns, Nm, Nh, Nd (e.g. 5m, 30m, 2h, 1d). Minimum granularity is 1 minute.
If no interval is specified, defaults to ${DEFAULT_INTERVAL}.claude-code-opensource/src/skills/bundled/scheduleRemoteAgents.ts:远程 Agent 调度的核心实现,包含环境发现与触发器配置。
📄 src/skills/bundled/scheduleRemoteAgents.ts — 远程 Agent 调度的核心实现,包含环境发现与触发器配置。
return `# Schedule Remote Agents
You are helping the user schedule, update, list, or run **remote** Claude Code agents. These are NOT local cron jobs — each trigger spawns a fully isolated remote session (CCR) in Anthropic's cloud infrastructure on a cron schedule. The agent runs in a sandboxed environment with its own git checkout, tools, and optional MCP connections.
## First Step
${firstStep}claude-code-opensource/src/tools/ScheduleCronTool/CronCreateTool.ts:任务创建、数量限制与 Teammate 校验。
📄 src/tools/ScheduleCronTool/CronCreateTool.ts — 任务创建、数量限制与 Teammate 校验。
// Teammates don't persist across sessions, so a durable teammate cron
// would orphan on restart (agentId would point to a nonexistent teammate).
if (input.durable && getTeammateContext()) {
return {
result: false,
message:
'durable crons are not supported for teammates (teammates do not persist across sessions)',
errorCode: 4,
}
}claude-code-opensource/src/tools/RemoteTriggerTool/prompt.ts:定义了模型如何与云端触发器进行交互。
📄 src/tools/RemoteTriggerTool/prompt.ts — 定义了模型如何与云端触发器进行交互。
export const REMOTE_TRIGGER_TOOL_NAME = 'RemoteTrigger'
export const DESCRIPTION =
'Manage scheduled remote Claude Code agents (triggers) via the claude.ai CCR API. Auth is handled in-process — the token never reaches the shell.'
export const PROMPT = `Call the claude.ai remote-trigger API. Use this instead of curl — the OAuth token is added automatically in-process and never exposed.
Actions:
- list: GET /v1/code/triggers
- get: GET /v1/code/triggers/{trigger_id}claude-code-opensource/src/utils/cronTasks.ts:任务模型定义、内存/磁盘分流存储与抖动计算。
📄 src/utils/cronTasks.ts — 任务模型定义、内存/磁盘分流存储与抖动计算。
export type CronTask = {
id: string
/** 5-field cron string (local time) — validated on write, re-validated on read. */
cron: string
/** Prompt to enqueue when the task fires. */
prompt: string
/** Epoch ms when the task was created. Anchor for missed-task detection. */
createdAt: number
/**
* Epoch ms of the most recent fire. Written back by the scheduler after
* each recurring fire so next-fire computation survives process restarts.
* The scheduler anchors first-sight from `lastFiredAt ?? createdAt` — a
* never-fired task uses createdAt (correct for pinned crons like
* `30 14 27 2 *` whose next-from-now is next year); a fired-before task
* reconstructs the same `nextFireAt` the prior process had in memory.
* Never set for one-shots (they're deleted on fire).
*/
lastFiredAt?: number
/** When true, the task reschedules after firing instead of being deleted. */
recurring?: boolean
/**
* When true, the task is exempt from recurringMaxAgeMs auto-expiry.
* System escape hatch for assistant mode's built-in tasks (catch-up/
* morning-checkin/dream) — the installer's writeIfMissing() skips existing
* files so re-install can't recreate them. Not settable via CronCreateTool;
* only written directly to scheduled_tasks.json by src/assistant/install.ts.
*/
permanent?: boolean
/**
* Runtime-only flag. false → session-scoped (never written to disk).claude-code-opensource/src/utils/cronScheduler.ts:调度引擎核心,负责 Idle 检测、过期检查与任务注入。
📄 src/utils/cronScheduler.ts — 调度引擎核心,负责 Idle 检测、过期检查与任务注入。
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import { cronToHuman } from './cron.js'claude-code-opensource/src/utils/cronTasksLock.ts:跨进程单主控锁机制,防止任务双响。
📄 src/utils/cronTasksLock.ts — 跨进程单主控锁机制,防止任务双响。
const LOCK_FILE_REL = join('.claude', 'scheduled_tasks.lock')
const schedulerLockSchema = lazySchema(() =>
z.object({
sessionId: z.string(),
pid: z.number(),
acquiredAt: z.number(),
}),