| 140 | } |
| 141 | |
| 142 | export function createCronScheduler( |
| 143 | options: CronSchedulerOptions, |
| 144 | ): CronScheduler { |
| 145 | const { |
| 146 | onFire, |
| 147 | isLoading, |
| 148 | assistantMode = false, |
| 149 | onFireTask, |
| 150 | onMissed, |
| 151 | dir, |
| 152 | lockIdentity, |
| 153 | getJitterConfig, |
| 154 | isKilled, |
| 155 | filter, |
| 156 | } = options |
| 157 | const lockOpts = dir || lockIdentity ? { dir, lockIdentity } : undefined |
| 158 | |
| 159 | // File-backed tasks only. Session tasks (durable: false) are NOT loaded |
| 160 | // here — they can be added/removed mid-session with no file event, so |
| 161 | // check() reads them fresh from bootstrap state on every tick instead. |
| 162 | let tasks: CronTask[] = [] |
| 163 | // Per-task next-fire times (epoch ms). |
| 164 | const nextFireAt = new Map<string, number>() |
| 165 | // Ids we've already enqueued a "missed task" prompt for — prevents |
| 166 | // re-asking on every file change before the user answers. |
| 167 | const missedAsked = new Set<string>() |
| 168 | // Tasks currently enqueued but not yet removed from the file. Prevents |
| 169 | // double-fire if the interval ticks again before removeCronTasks lands. |
| 170 | const inFlight = new Set<string>() |
| 171 | |
| 172 | let enablePoll: ReturnType<typeof setInterval> | null = null |
| 173 | let checkTimer: ReturnType<typeof setInterval> | null = null |
| 174 | let lockProbeTimer: ReturnType<typeof setInterval> | null = null |
| 175 | let watcher: FSWatcher | null = null |
| 176 | let stopped = false |
| 177 | let isOwner = false |
| 178 | |
| 179 | async function load(initial: boolean) { |
| 180 | const next = await readCronTasks(dir) |
| 181 | if (stopped) return |
| 182 | tasks = next |
| 183 | |
| 184 | // Only surface missed tasks on initial load. Chokidar-triggered |
| 185 | // reloads leave overdue tasks to check() (which anchors from createdAt |
| 186 | // and fires immediately). This avoids a misleading "missed while Claude |
| 187 | // was not running" prompt for tasks that became overdue mid-session. |
| 188 | // |
| 189 | // Recurring tasks are NOT surfaced or deleted — check() handles them |
| 190 | // correctly (fires on first tick, reschedules forward). Only one-shot |
| 191 | // missed tasks need user input (run once now, or discard forever). |
| 192 | if (!initial) return |
| 193 | |
| 194 | const now = Date.now() |
| 195 | const missed = findMissedTasks(next, now).filter( |
| 196 | t => !t.recurring && !missedAsked.has(t.id) && (!filter || filter(t)), |
| 197 | ) |
| 198 | if (missed.length > 0) { |
| 199 | for (const t of missed) { |