Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
17 kB · 458 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459// Scheduled prompts, stored in <project>/.claude/scheduled_tasks.json.//// Tasks come in two flavors:// - One-shot (recurring: false/undefined) — fire once, then auto-delete.// - Recurring (recurring: true) — fire on schedule, reschedule from now,// persist until explicitly deleted via CronDelete or auto-expire after// a configurable limit (DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs).//// File format:// { "tasks": [{ id, cron, prompt, createdAt, recurring?, permanent? }] }
import { randomUUID } from 'crypto'import { readFileSync } from 'fs'import { mkdir, writeFile } from 'fs/promises'import { join } from 'path'import { addSessionCronTask, getProjectRoot, getSessionCronTasks, removeSessionCronTasks,} from '../bootstrap/state.js'import { computeNextCronRun, parseCronExpression } from './cron.js'import { logForDebugging } from './debug.js'import { isFsInaccessible } from './errors.js'import { getFsImplementation } from './fsOperations.js'import { safeParseJSON } from './json.js'import { logError } from './log.js'import { jsonStringify } from './slowOperations.js'
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). * File-backed tasks leave this undefined; writeCronTasks strips it so * the on-disk shape stays { id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }. */ durable?: boolean /** * Runtime-only. When set, the task was created by an in-process teammate. * The scheduler routes fires to that teammate's queue instead of the main * REPL's. Never written to disk (teammate crons are always session-only). */ agentId?: string}
type CronFile = { tasks: CronTask[] }
const CRON_FILE_REL = join('.claude', 'scheduled_tasks.json')
/** * Path to the cron file. `dir` defaults to getProjectRoot() — pass it * explicitly from contexts that don't run through main.tsx (e.g. the Agent * SDK daemon, which has no bootstrap state). */export function getCronFilePath(dir?: string): string { return join(dir ?? getProjectRoot(), CRON_FILE_REL)}
/** * Read and parse .claude/scheduled_tasks.json. Returns an empty task list if the file * is missing, empty, or malformed. Tasks with invalid cron strings are * silently dropped (logged at debug level) so a single bad entry never * blocks the whole file. */export async function readCronTasks(dir?: string): Promise<CronTask[]> { const fs = getFsImplementation() let raw: string try { raw = await fs.readFile(getCronFilePath(dir), { encoding: 'utf-8' }) } catch (e: unknown) { if (isFsInaccessible(e)) return [] logError(e) return [] }
const parsed = safeParseJSON(raw, false) if (!parsed || typeof parsed !== 'object') return [] const file = parsed as Partial<CronFile> if (!Array.isArray(file.tasks)) return []
const out: CronTask[] = [] for (const t of file.tasks) { if ( !t || typeof t.id !== 'string' || typeof t.cron !== 'string' || typeof t.prompt !== 'string' || typeof t.createdAt !== 'number' ) { logForDebugging( `[ScheduledTasks] skipping malformed task: ${jsonStringify(t)}`, ) continue } if (!parseCronExpression(t.cron)) { logForDebugging( `[ScheduledTasks] skipping task ${t.id} with invalid cron '${t.cron}'`, ) continue } out.push({ id: t.id, cron: t.cron, prompt: t.prompt, createdAt: t.createdAt, ...(typeof t.lastFiredAt === 'number' ? { lastFiredAt: t.lastFiredAt } : {}), ...(t.recurring ? { recurring: true } : {}), ...(t.permanent ? { permanent: true } : {}), }) } return out}
/** * Sync check for whether the cron file has any valid tasks. Used by * cronScheduler.start() to decide whether to auto-enable. One file read. */export function hasCronTasksSync(dir?: string): boolean { let raw: string try { // eslint-disable-next-line custom-rules/no-sync-fs -- called once from cronScheduler.start() raw = readFileSync(getCronFilePath(dir), 'utf-8') } catch { return false } const parsed = safeParseJSON(raw, false) if (!parsed || typeof parsed !== 'object') return false const tasks = (parsed as Partial<CronFile>).tasks return Array.isArray(tasks) && tasks.length > 0}
/** * Overwrite .claude/scheduled_tasks.json with the given tasks. Creates .claude/ if * missing. Empty task list writes an empty file (rather than deleting) so * the file watcher sees a change event on last-task-removed. */export async function writeCronTasks( tasks: CronTask[], dir?: string,): Promise<void> { const root = dir ?? getProjectRoot() await mkdir(join(root, '.claude'), { recursive: true }) // Strip the runtime-only `durable` flag — everything on disk is durable // by definition, and keeping the flag out means readCronTasks() naturally // yields durable: undefined without having to set it explicitly. const body: CronFile = { tasks: tasks.map(({ durable: _durable, ...rest }) => rest), } await writeFile( getCronFilePath(root), jsonStringify(body, null, 2) + '\n', 'utf-8', )}
/** * Append a task. Returns the generated id. Caller is responsible for having * already validated the cron string (the tool does this via validateInput). * * When `durable` is false the task is held in process memory only * (bootstrap/state.ts) — it fires on schedule this session but is never * written to .claude/scheduled_tasks.json and dies with the process. The * scheduler merges session tasks into its tick loop directly, so no file * change event is needed. */export async function addCronTask( cron: string, prompt: string, recurring: boolean, durable: boolean, agentId?: string,): Promise<string> { // Short ID — 8 hex chars is plenty for MAX_JOBS=50, avoids slice/prefix // juggling between the tool layer (shows short IDs) and disk. const id = randomUUID().slice(0, 8) const task = { id, cron, prompt, createdAt: Date.now(), ...(recurring ? { recurring: true } : {}), } if (!durable) { addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) }) return id } const tasks = await readCronTasks() tasks.push(task) await writeCronTasks(tasks) return id}
/** * Remove tasks by id. No-op if none match (e.g. another session raced us). * Used for both fire-once cleanup and explicit CronDelete. * * When called with `dir` undefined (REPL path), also sweeps the in-memory * session store — the caller doesn't know which store an id lives in. * Daemon callers pass `dir` explicitly; they have no session, and the * `dir !== undefined` guard keeps this function from touching bootstrap * state on that path (tests enforce this). */export async function removeCronTasks( ids: string[], dir?: string,): Promise<void> { if (ids.length === 0) return // Sweep session store first. If every id was accounted for there, we're // done — skip the file read entirely. removeSessionCronTasks is a no-op // (returns 0) on miss, so pre-existing durable-delete paths fall through // without allocating. if (dir === undefined && removeSessionCronTasks(ids) === ids.length) { return } const idSet = new Set(ids) const tasks = await readCronTasks(dir) const remaining = tasks.filter(t => !idSet.has(t.id)) if (remaining.length === tasks.length) return await writeCronTasks(remaining, dir)}
/** * Stamp `lastFiredAt` on the given recurring tasks and write back. Batched * so N fires in one scheduler tick = one read-modify-write, not N. Only * touches file-backed tasks — session tasks die with the process, no point * persisting their fire time. No-op if none of the ids match (task was * deleted between fire and write — e.g. user ran CronDelete mid-tick). * * Scheduler lock means at most one process calls this; chokidar picks up * the write and triggers a reload which re-seeds `nextFireAt` from the * just-written `lastFiredAt` — idempotent (same computation, same answer). */export async function markCronTasksFired( ids: string[], firedAt: number, dir?: string,): Promise<void> { if (ids.length === 0) return const idSet = new Set(ids) const tasks = await readCronTasks(dir) let changed = false for (const t of tasks) { if (idSet.has(t.id)) { t.lastFiredAt = firedAt changed = true } } if (!changed) return await writeCronTasks(tasks, dir)}
/** * File-backed tasks + session-only tasks, merged. Session tasks get * `durable: false` so callers can distinguish them. File tasks are * returned as-is (durable undefined → truthy). * * Only merges when `dir` is undefined — daemon callers (explicit `dir`) * have no session store to merge with. */export async function listAllCronTasks(dir?: string): Promise<CronTask[]> { const fileTasks = await readCronTasks(dir) if (dir !== undefined) return fileTasks const sessionTasks = getSessionCronTasks().map(t => ({ ...t, durable: false as const, })) return [...fileTasks, ...sessionTasks]}
/** * Next fire time in epoch ms for a cron string, strictly after `fromMs`. * Returns null if invalid or no match in the next 366 days. */export function nextCronRunMs(cron: string, fromMs: number): number | null { const fields = parseCronExpression(cron) if (!fields) return null const next = computeNextCronRun(fields, new Date(fromMs)) return next ? next.getTime() : null}
/** * Cron scheduler tuning knobs. Sourced at runtime from the * `tengu_kairos_cron_config` GrowthBook JSON config (see cronJitterConfig.ts) * so ops can adjust behavior fleet-wide without shipping a client build. * Defaults here preserve the pre-config behavior exactly. */export type CronJitterConfig = { /** Recurring-task forward delay as a fraction of the interval between fires. */ recurringFrac: number /** Upper bound on recurring forward delay regardless of interval length. */ recurringCapMs: number /** One-shot backward lead: maximum ms a task may fire early. */ oneShotMaxMs: number /** * One-shot backward lead: minimum ms a task fires early when the minute-mod * gate matches. 0 = taskIds hashing near zero fire on the exact mark. Raise * this to guarantee nobody lands on the wall-clock boundary. */ oneShotFloorMs: number /** * Jitter fires landing on minutes where `minute % N === 0`. 30 → :00/:30 * (the human-rounding hotspots). 15 → :00/:15/:30/:45. 1 → every minute. */ oneShotMinuteMod: number /** * Recurring tasks auto-expire this many ms after creation (unless marked * `permanent`). Cron is the primary driver of multi-day sessions (p99 * uptime 61min → 53h post-#19931), and unbounded recurrence lets Tier-1 * heap leaks compound indefinitely. The default (7 days) covers "check * my PRs every hour this week" workflows while capping worst-case * session lifetime. Permanent tasks (assistant mode's catch-up/ * morning-checkin/dream) never age out — they can't be recreated if * deleted because install.ts's writeIfMissing() skips existing files. * * `0` = unlimited (tasks never auto-expire). */ recurringMaxAgeMs: number}
export const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig = { recurringFrac: 0.1, recurringCapMs: 15 * 60 * 1000, oneShotMaxMs: 90 * 1000, oneShotFloorMs: 0, oneShotMinuteMod: 30, recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000,}
/** * taskId is an 8-hex-char UUID slice (see {@link addCronTask}) → parse as * u32 → [0, 1). Stable across restarts, uniformly distributed across the * fleet. Non-hex ids (hand-edited JSON) fall back to 0 = no jitter. */function jitterFrac(taskId: string): number { const frac = parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000 return Number.isFinite(frac) ? frac : 0}
/** * Same as {@link nextCronRunMs}, plus a deterministic per-task delay to * avoid a thundering herd when many sessions schedule the same cron string * (e.g. `0 * * * *` → everyone hits inference at :00). * * The delay is proportional to the current gap between fires * ({@link CronJitterConfig.recurringFrac}, capped at * {@link CronJitterConfig.recurringCapMs}) so at defaults an hourly task * spreads across [:00, :06) but a per-minute task only spreads by a few * seconds. * * Only used for recurring tasks. One-shot tasks use * {@link oneShotJitteredNextCronRunMs} (backward jitter, minute-gated). */export function jitteredNextCronRunMs( cron: string, fromMs: number, taskId: string, cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,): number | null { const t1 = nextCronRunMs(cron, fromMs) if (t1 === null) return null const t2 = nextCronRunMs(cron, t1) // No second match in the next year (e.g. pinned date) → nothing to // proportion against, and near-certainly not a herd risk. Fire on t1. if (t2 === null) return t1 const jitter = Math.min( jitterFrac(taskId) * cfg.recurringFrac * (t2 - t1), cfg.recurringCapMs, ) return t1 + jitter}
/** * Same as {@link nextCronRunMs}, minus a deterministic per-task lead time * when the fire time lands on a minute boundary matching * {@link CronJitterConfig.oneShotMinuteMod}. * * One-shot tasks are user-pinned ("remind me at 3pm") so delaying them * breaks the contract — but firing slightly early is invisible and spreads * the inference spike from everyone picking the same round wall-clock time. * At defaults (mod 30, max 90 s, floor 0) only :00 and :30 get jitter, * because humans round to the half-hour. * * During an incident, ops can push `tengu_kairos_cron_config` with e.g. * `{oneShotMinuteMod: 15, oneShotMaxMs: 300000, oneShotFloorMs: 30000}` to * spread :00/:15/:30/:45 fires across a [t-5min, t-30s] window — every task * gets at least 30 s of lead, so nobody lands on the exact mark. * * Checks the computed fire time rather than the cron string so * `0 15 * * *`, step expressions, and `0,30 9 * * *` all get jitter * when they land on a matching minute. Clamped to `fromMs` so a task created * inside its own jitter window doesn't fire before it was created. */export function oneShotJitteredNextCronRunMs( cron: string, fromMs: number, taskId: string, cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,): number | null { const t1 = nextCronRunMs(cron, fromMs) if (t1 === null) return null // Cron resolution is 1 minute → computed times always have :00 seconds, // so a minute-field check is sufficient to identify the hot marks. // getMinutes() (local), not getUTCMinutes(): cron is evaluated in local // time, and "user picked a round time" means round in *their* TZ. In // half-hour-offset zones (India UTC+5:30) local :00 is UTC :30 — the // UTC check would jitter the wrong marks. if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0) return t1 // floor + frac * (max - floor) → uniform over [floor, max). With floor=0 // this reduces to the original frac * max. With floor>0, even a taskId // hashing to 0 gets `floor` ms of lead — nobody fires on the exact mark. const lead = cfg.oneShotFloorMs + jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs) // t1 > fromMs is guaranteed by nextCronRunMs (strictly after), so the // max() only bites when the task was created inside its own lead window. return Math.max(t1 - lead, fromMs)}
/** * A task is "missed" when its next scheduled run (computed from createdAt) * is in the past. Surfaced to the user at startup. Works for both one-shot * and recurring tasks — a recurring task whose window passed while Claude * was down is still "missed". */export function findMissedTasks(tasks: CronTask[], nowMs: number): CronTask[] { return tasks.filter(t => { const next = nextCronRunMs(t.cron, t.createdAt) return next !== null && next < nowMs })}