()
| 228 | } |
| 229 | |
| 230 | function check() { |
| 231 | if (isKilled?.()) return |
| 232 | if (isLoading() && !assistantMode) return |
| 233 | const now = Date.now() |
| 234 | const seen = new Set<string>() |
| 235 | // File-backed recurring tasks that fired this tick. Batched into one |
| 236 | // markCronTasksFired call after the loop so N fires = one write. Session |
| 237 | // tasks excluded — they die with the process, no point persisting. |
| 238 | const firedFileRecurring: string[] = [] |
| 239 | // Read once per tick. REPL callers pass getJitterConfig backed by |
| 240 | // GrowthBook so a config push takes effect without restart. Daemon and |
| 241 | // SDK callers omit it and get DEFAULT_CRON_JITTER_CONFIG (safe — jitter |
| 242 | // is an ops lever for REPL fleet load-shedding, not a daemon concern). |
| 243 | const jitterCfg = getJitterConfig?.() ?? DEFAULT_CRON_JITTER_CONFIG |
| 244 | |
| 245 | // Shared loop body. `isSession` routes the one-shot cleanup path: |
| 246 | // session tasks are removed synchronously from memory, file tasks go |
| 247 | // through the async removeCronTasks + chokidar reload. |
| 248 | function process(t: CronTask, isSession: boolean) { |
| 249 | if (filter && !filter(t)) return |
| 250 | seen.add(t.id) |
| 251 | if (inFlight.has(t.id)) return |
| 252 | |
| 253 | let next = nextFireAt.get(t.id) |
| 254 | if (next === undefined) { |
| 255 | // First sight — anchor from lastFiredAt (recurring) or createdAt. |
| 256 | // Never-fired recurring tasks use createdAt: if isLoading delayed |
| 257 | // this tick past the fire time, anchoring from `now` would compute |
| 258 | // next-year for pinned crons (`30 14 27 2 *`). Fired-before tasks |
| 259 | // use lastFiredAt: the reschedule below writes `now` back to disk, |
| 260 | // so on next process spawn first-sight computes the SAME newNext we |
| 261 | // set in-memory here. Without this, a daemon child despawning on |
| 262 | // idle loses nextFireAt and the next spawn re-anchors from 10-day- |
| 263 | // old createdAt → fires every task every cycle. |
| 264 | next = t.recurring |
| 265 | ? (jitteredNextCronRunMs( |
| 266 | t.cron, |
| 267 | t.lastFiredAt ?? t.createdAt, |
| 268 | t.id, |
| 269 | jitterCfg, |
| 270 | ) ?? Infinity) |
| 271 | : (oneShotJitteredNextCronRunMs( |
| 272 | t.cron, |
| 273 | t.createdAt, |
| 274 | t.id, |
| 275 | jitterCfg, |
| 276 | ) ?? Infinity) |
| 277 | nextFireAt.set(t.id, next) |
| 278 | logForDebugging( |
| 279 | `[ScheduledTasks] scheduled ${t.id} for ${next === Infinity ? 'never' : new Date(next).toISOString()}`, |
| 280 | ) |
| 281 | } |
| 282 | |
| 283 | if (now < next) return |
| 284 | |
| 285 | logForDebugging( |
| 286 | `[ScheduledTasks] firing ${t.id}${t.recurring ? ' (recurring)' : ''}`, |
| 287 | ) |
nothing calls this directly
no test coverage detected